feat: support updating the view name and icon through document (#3099)

* feat: support updating the view name and icon through document

* fix: store expand pages

* fix: refactor text link

* fix: update cargo.toml

* fix: update test

* fix: update event map

* fix: move deal with icon codes to a single file

* fix: delete useless code from flutter

* fix: document banner

* fix: build error

* fix: update rust library

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Kilu.He 2023-08-04 19:27:14 +08:00 committed by GitHub
parent 70914e6228
commit 16a01e11ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 1293 additions and 1295 deletions

View File

@ -1,7 +1,6 @@
import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
@ -115,18 +114,6 @@ class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
(err) => Log.error(err),
);
});
// Set the icon and cover of the view
ViewBackendService.updateView(
viewId: viewId,
iconURL: iconURL,
coverURL: coverURL,
).then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
}
}
@ -136,7 +123,7 @@ class RowBannerEvent with _$RowBannerEvent {
const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) =
_DidReceiveRowMeta;
const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) =
_DidReceiveFieldUdate;
_DidReceiveFieldUpdate;
const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon;
const factory RowBannerEvent.setCover(String coverURL) = _SetCover;
}

View File

@ -44,7 +44,7 @@ extension ViewExtension on ViewPB {
return widget;
}
Widget icon() {
Widget defaultIcon() {
final iconName = switch (layout) {
ViewLayoutPB.Board => 'editor/board',
ViewLayoutPB.Calendar => 'editor/calendar',

View File

@ -138,8 +138,6 @@ class ViewBackendService {
static Future<Either<ViewPB, FlowyError>> updateView({
required String viewId,
String? name,
String? iconURL,
String? coverURL,
bool? isFavorite,
}) {
final payload = UpdateViewPayloadPB.create()..viewId = viewId;
@ -148,14 +146,6 @@ class ViewBackendService {
payload.name = name;
}
if (iconURL != null) {
payload.iconUrl = iconURL;
}
if (coverURL != null) {
payload.coverUrl = coverURL;
}
if (isFavorite != null) {
payload.isFavorite = isFavorite;
}

View File

@ -229,7 +229,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
// icon
SizedBox.square(
dimension: 16,
child: widget.view.icon(),
child: widget.view.defaultIcon(),
),
const HSpace(5),
// title

View File

@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"collab",
@ -1021,7 +1021,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"bytes",
@ -1039,7 +1039,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bytes",
"collab-sync",
@ -1057,7 +1057,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"async-trait",
@ -1084,7 +1084,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"proc-macro2",
"quote",
@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"collab",
@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"chrono",
@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bincode",
"chrono",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"async-trait",
@ -1185,7 +1185,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bytes",
"collab",

View File

@ -34,20 +34,20 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
#collab = { path = "../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }
#collab-document = { path = "../../AppFlowy-Collab/collab-document" }
#collab-database = { path = "../../AppFlowy-Collab/collab-database" }
#appflowy-integrate = { path = "../../AppFlowy-Collab/appflowy-integrate" }
#collab-plugins = { path = "../../AppFlowy-Collab/collab-plugins" }
#collab = { path = "../../../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../../../AppFlowy-Collab/collab-folder" }
#collab-document = { path = "../../../../AppFlowy-Collab/collab-document" }
#collab-database = { path = "../../../../AppFlowy-Collab/collab-database" }
#appflowy-integrate = { path = "../../../../AppFlowy-Collab/appflowy-integrate" }
#collab-plugins = { path = "../../../../AppFlowy-Collab/collab-plugins" }

View File

@ -1,8 +1,8 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { UserSettingController } from '$app/stores/effects/user/user_setting_controller';
import { currentUserActions } from '$app_reducers/current-user/slice';
import { Theme as ThemeType, Theme, ThemeMode } from '$app/interfaces';
import { Theme as ThemeType, ThemeMode } from '$app/interfaces';
import { createTheme } from '@mui/material/styles';
import { getDesignTokens } from '$app/utils/mui';
import { useTranslation } from 'react-i18next';
@ -18,28 +18,18 @@ export function useUserSetting() {
return controller;
}, [currentUser?.id]);
const loadUserSetting = useCallback(async () => {
if (!userSettingController) return;
const settings = await userSettingController.getAppearanceSetting();
if (!settings) return;
dispatch(currentUserActions.setUserSetting(settings));
await i18n.changeLanguage(settings.language);
}, [dispatch, i18n, userSettingController]);
useEffect(() => {
userSettingController?.getAppearanceSetting().then((res) => {
if (!res) return;
const locale = res.locale;
let language = 'en';
if (locale.language_code && locale.country_code) {
language = `${locale.language_code}-${locale.country_code}`;
} else if (locale.language_code) {
language = locale.language_code;
}
dispatch(
currentUserActions.setUserSetting({
themeMode: res.theme_mode,
theme: res.theme as Theme,
language: language,
})
);
i18n.changeLanguage(language);
});
}, [i18n, dispatch, userSettingController]);
void loadUserSetting();
}, [loadUserSetting]);
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
return state.currentUser.userSetting || {};

View File

@ -1,10 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import emojiData, { EmojiMartData } from '@emoji-mart/data';
import { init, FrequentlyUsed, getEmojiDataFromNative, Store } from 'emoji-mart';
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 const EMOJI_SIZE = 32;
export const PER_ROW_EMOJI_COUNT = 13;
export const MAX_FREQUENTLY_ROW_COUNT = 2;
export interface EmojiCategory {
id: string;
emojis: Emoji[];
@ -15,48 +23,87 @@ interface Emoji {
name: string;
native: string;
}
export function useLoadEmojiData({ skin }: { skin: number }) {
export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
const [searchValue, setSearchValue] = useState('');
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
const [skin, setSkin] = useState<number>(() => {
return Number(Store.get('skin')) || 0;
});
const onSkinChange = useCallback((val: number) => {
setSkin(val);
Store.set('skin', String(val));
}, []);
const loadEmojiData = useCallback(
async (searchVal?: string) => {
const { emojis, categories } = emojiData as EmojiMartData;
const filteredCategories = categories
.map((category) => {
const { id, emojis: categoryEmojis } = category;
return {
id,
emojis: categoryEmojis
.filter((emojiId) => {
const emoji = emojis[emojiId];
if (!searchVal) return true;
return filterSearchValue(emoji, searchVal);
})
.map((emojiId) => {
const emoji = emojis[emojiId];
const { name, skins } = emoji;
return {
id: emojiId,
name,
native: skins[skin] ? skins[skin].native : skins[0].native,
};
}),
};
})
.filter((category) => category.emojis.length > 0);
setEmojiCategories(filteredCategories);
},
[skin]
);
useEffect(() => {
const { emojis, categories } = emojiData as EmojiMartData;
void (async () => {
await init({ data: emojiData, maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, perLine: PER_ROW_EMOJI_COUNT });
await loadEmojiData();
})();
}, [loadEmojiData]);
const emojiCategories = categories
.map((category) => {
const { id, emojis: categoryEmojis } = category;
useEffect(() => {
void loadEmojiData(searchValue);
}, [loadEmojiData, searchValue]);
return {
id,
emojis: categoryEmojis
.filter((emojiId) => {
const emoji = emojis[emojiId];
const onSelect = useCallback(
async (native: string) => {
onEmojiSelect(native);
if (!native) {
return;
}
if (!searchValue) return true;
return filterSearchValue(emoji, searchValue);
})
.map((emojiId) => {
const emoji = emojis[emojiId];
const { id, name, skins } = emoji;
const data = await getEmojiDataFromNative(native);
return {
id,
name,
native: skins[skin] ? skins[skin].native : skins[0].native,
};
}),
};
})
.filter((category) => category.emojis.length > 0);
setEmojiCategories(emojiCategories);
}, [skin, searchValue]);
FrequentlyUsed.add(data);
},
[onEmojiSelect]
);
return {
emojiCategories,
skin,
setSearchValue,
searchValue,
onSelect,
onSkinChange,
skin,
};
}
@ -124,9 +171,8 @@ export function useVirtualizedCategories({ count }: { count: number }) {
count,
getScrollElement: () => ref.current,
estimateSize: () => {
return 60;
return EMOJI_SIZE;
},
overscan: 3,
});
return { virtualize, ref };

View File

@ -1,7 +1,9 @@
import React, { useCallback, useMemo } from 'react';
import {
EMOJI_SIZE,
EmojiCategory,
getRowsWithCategories,
PER_ROW_EMOJI_COUNT,
useVirtualizedCategories,
} from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
import { useTranslation } from 'react-i18next';
@ -16,7 +18,7 @@ function EmojiPickerCategories({
}) {
const { t } = useTranslation();
const rows = useMemo(() => {
return getRowsWithCategories(emojiCategories, 13);
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
}, [emojiCategories]);
const { ref, virtualize } = useVirtualizedCategories({
@ -27,6 +29,7 @@ function EmojiPickerCategories({
const getCategoryName = useCallback(
(id: string) => {
const i18nName: Record<string, string> = {
frequent: t('emoji.categories.frequentlyUsed'),
people: t('emoji.categories.people'),
nature: t('emoji.categories.nature'),
foods: t('emoji.categories.food'),
@ -43,7 +46,12 @@ function EmojiPickerCategories({
);
return (
<div ref={ref} className={'mt-2 w-[416px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden'}>
<div
ref={ref}
className={`mt-2 w-[${
EMOJI_SIZE * PER_ROW_EMOJI_COUNT
}px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden`}
>
<div
style={{
height: virtualize.getTotalSize(),
@ -72,7 +80,14 @@ function EmojiPickerCategories({
<div className={'flex'}>
{item.emojis?.map((emoji) => {
return (
<div key={emoji.id} className={'flex h-[32px] w-[32px] items-center justify-center'}>
<div
key={emoji.id}
style={{
width: EMOJI_SIZE,
height: EMOJI_SIZE,
}}
className={`flex items-center justify-center`}
>
<IconButton
size={'small'}
onClick={() => {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Box, IconButton } from '@mui/material';
import { DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material';
import { Circle, 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';
@ -12,26 +12,26 @@ import { useTranslation } from 'react-i18next';
const skinTones = [
{
value: 0,
label: '✋',
color: '#ffc93a',
},
{
label: '✋🏻',
color: '#ffdab7',
value: 1,
},
{
label: '✋🏼',
color: '#e7b98f',
value: 2,
},
{
label: '✋🏽',
color: '#c88c61',
value: 3,
},
{
label: '✋🏾',
color: '#a46134',
value: 4,
},
{
label: '✋🏿',
color: '#5d4437',
value: 5,
},
];
@ -78,7 +78,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
<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}
<Circle
style={{
fill: skinTones[skin].color,
}}
/>
</IconButton>
</div>
</Tooltip>
@ -100,7 +104,7 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
<div className={'mx-0.5'} key={skinTone.value}>
<IconButton
style={{
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : 'transparent',
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
}}
size={'small'}
onClick={() => {
@ -108,7 +112,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
popoverProps.onClose?.();
}}
>
{skinTone.label}
<Circle
style={{
fill: skinTone.color,
}}
/>
</IconButton>
</div>
))}

View File

@ -1,33 +1,28 @@
import React, { useState } from 'react';
import React 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';
import { useLoadEmojiData } from './EmojiPicker.hooks';
import EmojiPickerHeader from './EmojiPickerHeader';
import EmojiPickerCategories from './EmojiPickerCategories';
interface Props {
onEmojiSelect: (emoji: string) => void;
}
function EmojiPickerComponent({ onEmojiSelect }: Props) {
const [skin, setSkin] = useState(0);
const { emojiCategories, setSearchValue, searchValue } = useLoadEmojiData({
skin,
});
function EmojiPicker(props: Props) {
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
return (
<div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
<EmojiPickerHeader
onEmojiSelect={onEmojiSelect}
onEmojiSelect={onSelect}
skin={skin}
onSkinSelect={setSkin}
onSkinSelect={onSkinChange}
searchValue={searchValue}
onSearchChange={setSearchValue}
/>
<EmojiPickerCategories onEmojiSelect={onEmojiSelect} emojiCategories={emojiCategories} />
<EmojiPickerCategories onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
</div>
);
}
export default EmojiPickerComponent;
export default EmojiPicker;

View File

@ -1,7 +1,8 @@
import { t } from 'i18next';
import { AppflowyLogo } from '../../_shared/svg/AppflowyLogo';
import { Button } from '../../_shared/Button';
import { useLogin } from '../Login/Login.hooks';
import Button from '@mui/material/Button';
export const GetStarted = () => {
const { onAutoSignInClick } = useLogin();
@ -20,8 +21,8 @@ export const GetStarted = () => {
</span>
</div>
<div id='Get-Started' className='flex w-full max-w-[340px] flex-col gap-6 ' aria-label='Get-Started'>
<Button size={'primary'} onClick={() => onAutoSignInClick()}>
<div id='Get-Started' className='flex w-full max-w-[340px] flex-col ' aria-label='Get-Started'>
<Button size={'large'} variant={'contained'} onClick={() => onAutoSignInClick()}>
{t('signUp.getStartedText')}
</Button>
</div>

View File

@ -10,7 +10,7 @@ import { get } from '$app/utils/tool';
const headingBlockTopOffset: Record<number, string> = {
1: '0.4rem',
2: '0.2rem',
2: '0.35rem',
3: '0.15rem',
};

View File

@ -0,0 +1,72 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { updatePageIcon } from '$app_reducers/pages/async_actions';
import { useCallback, useMemo } from 'react';
import { ViewIconTypePB } from '@/services/backend';
import { CoverType } from '$app/interfaces/document';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
export const heightCls = {
cover: 'h-[220px]',
icon: 'h-[80px]',
coverAndIcon: 'h-[250px]',
none: 'h-0',
};
export function useDocumentBanner(id: string) {
const dispatch = useAppDispatch();
const { docId, controller } = useSubscribeDocument();
const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon);
const { node } = useSubscribeNode(id);
const { cover, coverType } = node.data;
const onUpdateIcon = useCallback(
(icon: string) => {
dispatch(
updatePageIcon({
id: docId,
icon: icon
? {
ty: ViewIconTypePB.Emoji,
value: icon,
}
: undefined,
})
);
},
[dispatch, docId]
);
const onUpdateCover = useCallback(
(coverType: CoverType | null, cover: string | null) => {
dispatch(
updateNodeDataThunk({
id,
data: {
coverType: coverType || '',
cover: cover || '',
},
controller,
})
);
},
[controller, dispatch, id]
);
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 {
onUpdateCover,
onUpdateIcon,
className,
icon,
cover,
coverType,
node,
};
}

View File

@ -1,13 +1,14 @@
import React, { useCallback, useState } from 'react';
import Popover from '@mui/material/Popover';
import EmojiPicker from '$app/components/_shared/EmojiPicker';
import { PageIcon } from '$app_reducers/pages/slice';
function DocumentIcon({
icon,
className,
onUpdateIcon,
}: {
icon?: string;
icon?: PageIcon;
className?: string;
onUpdateIcon: (icon: string) => void;
}) {
@ -41,13 +42,15 @@ function DocumentIcon({
<>
<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}
{icon.value}
</div>
</div>
<Popover
open={open}
anchorReference='anchorPosition'
anchorPosition={anchorPosition}
disableAutoFocus
disableRestoreFocus
onClose={() => setAnchorPosition(undefined)}
>
<EmojiPicker onEmojiSelect={onEmojiSelect} />

View File

@ -2,18 +2,23 @@ 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 { BlockType, CoverType, NestedBlock } from '$app/interfaces/document';
import { randomColor } from '$app/components/document/DocumentBanner/cover/config';
import { randomEmoji } from '$app/utils/document/emoji';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
interface Props {
node: NestedBlock<BlockType.PageBlock>;
onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
onUpdateCover: (coverType: CoverType, cover: string) => void;
onUpdateIcon: (icon: string) => void;
}
function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
const { t } = useTranslation();
const showAddIcon = !node.data.icon;
const { docId } = useSubscribeDocument();
const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon);
const showAddIcon = !icon;
const showAddCover = !node.data.cover;
const onAddIcon = useCallback(() => {
@ -25,7 +30,7 @@ function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
const onAddCover = useCallback(() => {
const color = randomColor();
onUpdateCover('color', color);
onUpdateCover(CoverType.Color, color);
}, [onUpdateCover]);
return (

View File

@ -1,9 +1,8 @@
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';
import ChangeCoverPopover from '$app/components/document/DocumentBanner/cover/ChangeCoverPopover';
import { CoverType } from '$app/interfaces/document';
function ChangeCoverButton({
visible,
@ -13,8 +12,8 @@ function ChangeCoverButton({
}: {
visible: boolean;
cover: string;
coverType: 'image' | 'color';
onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
coverType: CoverType;
onUpdateCover: (coverType: CoverType | null, cover: string | null) => void;
}) {
const { t } = useTranslation();
const [anchorPosition, setAnchorPosition] = useState<undefined | { top: number; left: number }>(undefined);
@ -32,7 +31,7 @@ function ChangeCoverButton({
}, []);
const onDeleteCover = useCallback(() => {
onUpdateCover('', '');
onUpdateCover(null, null);
}, [onUpdateCover]);
return (

View File

@ -1,30 +1,30 @@
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';
import React, { useRef } from 'react';
import Popover from '@mui/material/Popover';
import ChangeColors from '$app/components/document/DocumentBanner/cover/ChangeColors';
import ChangeImages from '$app/components/document/DocumentBanner/cover/ChangeImages';
import { CoverType } from '$app/interfaces/document';
function ChangeCoverPopover({
open,
anchorPosition,
onClose,
coverType,
cover,
onUpdateCover,
}: {
open: boolean;
anchorPosition?: { top: number; left: number };
onClose: () => void;
coverType: 'image' | 'color';
coverType: CoverType;
cover: string;
onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
onUpdateCover: (coverType: CoverType, cover: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
return (
<Popover
open={open}
disableAutoFocus
disableRestoreFocus
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
onClose={onClose}
@ -50,11 +50,11 @@ function ChangeCoverPopover({
>
<ChangeColors
onChange={(color) => {
onUpdateCover('color', color);
onUpdateCover(CoverType.Color, color);
}}
cover={cover}
/>
<ChangeImages cover={cover} onChange={(url) => onUpdateCover('image', url)} />
<ChangeImages cover={cover} onChange={(url) => onUpdateCover(CoverType.Image, url)} />
</div>
</Popover>
);

View File

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import GalleryList from '$app/components/document/DocumentTitle/cover/GalleryList';
import GalleryList from '$app/components/document/DocumentBanner/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';
import { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem';
function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) {
const { t } = useTranslation();

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import ChangeCoverButton from '$app/components/document/DocumentTitle/cover/ChangeCoverButton';
import ChangeCoverButton from '$app/components/document/DocumentBanner/cover/ChangeCoverButton';
import { readImage } from '$app/utils/document/image';
import { CoverType } from '$app/interfaces/document';
function DocumentCover({
cover,
@ -9,9 +10,9 @@ function DocumentCover({
onUpdateCover,
}: {
cover?: string;
coverType?: 'image' | 'color';
coverType?: CoverType;
className?: string;
onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
onUpdateCover: (coverType: CoverType | null, cover: string | null) => void;
}) {
const [hover, setHover] = useState(false);
const [leftOffset, setLeftOffset] = useState(0);
@ -55,7 +56,7 @@ function DocumentCover({
}, [handleWidthChange]);
useEffect(() => {
if (coverType === 'image' && cover) {
if (coverType === CoverType.Image && cover) {
void (async () => {
const src = await readImage(cover);
@ -75,8 +76,11 @@ function DocumentCover({
}}
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 }} />}
{coverType === CoverType.Image ? (
<img src={coverSrc} className={'h-full w-full object-cover'} />
) : (
<div className={'h-full w-full'} style={{ backgroundColor: cover }} />
)}
<ChangeCoverButton onUpdateCover={onUpdateCover} visible={hover} cover={cover} coverType={coverType} />
</div>
);

View File

@ -7,7 +7,7 @@ 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';
import GalleryItem, { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem';
interface Props {
onSelected: (image: Image) => void;

View File

@ -0,0 +1,30 @@
import { heightCls, useDocumentBanner } from './DocumentBanner.hooks';
import TitleButtonGroup from './TitleButtonGroup';
import DocumentCover from './cover/DocumentCover';
import DocumentIcon from './DocumentIcon';
function DocumentBanner({ id, hover }: { id: string; hover: boolean }) {
const { onUpdateCover, node, onUpdateIcon, icon, cover, className, coverType } = useDocumentBanner(id);
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>
<div
style={{
opacity: hover ? 1 : 0,
}}
>
<TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
</div>
</>
);
}
export default DocumentBanner;

View File

@ -1,47 +1,28 @@
import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
import { useCallback } from 'react';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { useEffect } from 'react';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppDispatch } from '$app/stores/store';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { documentActions } from '$app_reducers/document/slice';
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 { docId } = useSubscribeDocument();
const page = useAppSelector((state) => state.pages.pageMap[docId]);
const onUpdateCover = useCallback(
(coverType: 'image' | 'color' | '', cover: string) => {
useEffect(() => {
if (page) {
dispatch(
updateNodeDataThunk({
id,
data: {
cover,
coverType,
},
controller,
documentActions.updateRootNodeDelta({
docId,
delta: [{ insert: page.name }],
rootId: id,
})
);
},
[controller, dispatch, id]
);
}
}, [dispatch, docId, id, page]);
return {
node,
onUpdateCover,
onUpdateIcon,
};
}

View File

@ -1,44 +0,0 @@
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

@ -2,11 +2,10 @@ 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';
import DocumentBanner from '$app/components/document/DocumentBanner';
export default function DocumentTitle({ id }: { id: string }) {
const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id);
const { node } = useDocumentTitle(id);
const { t } = useTranslation();
const [hover, setHover] = useState(false);
@ -14,14 +13,7 @@ export default function DocumentTitle({ id }: { id: string }) {
return (
<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>
<DocumentBanner id={node.id} hover={hover} />
<div data-block-id={node.id} className='doc-title relative text-4xl font-bold'>
<TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
</div>

View File

@ -1,11 +1,9 @@
import React from 'react';
import BlockSideToolbar from '../BlockSideToolbar';
import BlockSelection from '../BlockSelection';
import TextActionMenu from '$app/components/document/TextActionMenu';
import BlockSlash from '$app/components/document/BlockSlash';
import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
@ -18,7 +16,6 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
<TextActionMenu container={container} />
<BlockSelection container={container} />
<BlockSlash container={container} />
<LinkEditPopover />
<TemporaryPopover />
</>
);

View File

@ -10,8 +10,8 @@ export default function QuoteBlock({
childIds?: string[];
}) {
return (
<div className={'py-[2px]'}>
<div className={'border-l-4 border-solid border-fill-default px-3 '}>
<div className={'py-[2px] pl-0.5'}>
<div className={'border-l-4 border-solid border-fill-default pl-3'}>
<TextBlock node={node} />
<NodeChildren childIds={childIds} />
</div>

View File

@ -4,7 +4,6 @@ import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME } from '$app/constants/document/name';
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
@ -57,14 +56,6 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[controller, dispatch, isActive]
);
const addLink = useCallback(() => {
dispatch(
newLinkThunk({
docId,
})
);
}, [dispatch, docId]);
const addTemporaryInput = useCallback(
(type: TemporaryType) => {
dispatch(createTemporary({ type, docId }));
@ -103,12 +94,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
case TextAction.Code:
return toggleFormat(format);
case TextAction.Link:
return addLink();
return addTemporaryInput(TemporaryType.Link);
case TextAction.Equation:
return addTemporaryInput(TemporaryType.Equation);
}
},
[addLink, addTemporaryInput, toggleFormat]
[addTemporaryInput, toggleFormat]
);
const formatIcon = useMemo(() => {

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useVirtualizedList } from './VirtualizedList.hooks';
import DocumentTitle from '../DocumentTitle';
import Overlay from '../Overlay';
@ -15,9 +14,8 @@ export default function VirtualizedList({
node: Node;
renderNode: (nodeId: string) => JSX.Element;
}) {
const { virtualize, parentRef } = useVirtualizedList(childIds.length);
const { virtualize, parentRef } = useVirtualizedList(childIds.length + 1);
const virtualItems = virtualize.getVirtualItems();
const { docId } = useSubscribeDocument();
return (
@ -46,12 +44,14 @@ export default function VirtualizedList({
}}
>
{virtualItems.map((virtualRow) => {
const id = childIds[virtualRow.index];
const isDocumentTitle = virtualRow.index === 0;
const id = isDocumentTitle ? node.id : childIds[virtualRow.index - 1];
return (
<div className={'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
{renderNode(id)}
<div className={isDocumentTitle ? '' : 'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
{
isDocumentTitle ? <DocumentTitle id={node.id} /> : renderNode(id)
}
</div>
);
})}

View File

@ -22,7 +22,6 @@ function InlineContainer({
}: {
getSelection: (node: Element) => RangeStaticNoId | null;
children: React.ReactNode;
formula: string;
selectedText: string;
isLast: boolean;
isFirst: boolean;
@ -52,7 +51,7 @@ function InlineContainer({
selection,
selectedText,
type: temporaryType,
data: temporaryData as { latex: string },
data: temporaryData
},
})
);

View File

@ -0,0 +1,67 @@
import React, { useCallback, useContext, useRef } from 'react';
import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppDispatch } from '$app/stores/store';
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
function LinkInline({
children,
getSelection,
selectedText,
temporaryType,
data,
}: {
getSelection: (node: Element) => RangeStaticNoId | null;
children: React.ReactNode;
selectedText: string;
temporaryType: TemporaryType;
data: {
href?: string;
};
}) {
const id = useContext(NodeIdContext);
const { docId } = useSubscribeDocument();
const ref = useRef<HTMLAnchorElement>(null);
const dispatch = useAppDispatch();
const onClick = useCallback(
(e: React.MouseEvent) => {
if (!ref.current) return;
const selection = getSelection(ref.current);
if (!selection) return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
e.stopPropagation();
e.preventDefault();
dispatch(
createTemporary({
docId,
state: {
id,
selection,
selectedText,
type: temporaryType,
data: {
href: data.href,
text: selectedText,
},
},
})
);
},
[data, dispatch, docId, getSelection, id, selectedText, temporaryType]
);
return (
<>
<span onClick={onClick} ref={ref} className='cursor-pointer text-text-link-default'>
<span className={' border-b-[1px] border-b-text-link-default'}>{children}</span>
</span>
</>
);
}
export default LinkInline;

View File

@ -1,12 +1,11 @@
import { ReactEditor, RenderLeafProps } from 'slate-react';
import { BaseText } from 'slate';
import { useCallback, useRef } from 'react';
import TextLink from '../TextLink';
import { converToIndexLength } from '$app/utils/document/slate_editor';
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
import { TemporaryType } from '$app/interfaces/document';
import LinkInline from '$app/components/document/_shared/InlineBlock/LinkInline';
interface Attributes {
bold?: boolean;
@ -17,8 +16,6 @@ interface Attributes {
selection_high_lighted?: boolean;
href?: string;
prism_token?: string;
link_selection_lighted?: boolean;
link_placeholder?: string;
temporary?: boolean;
formula?: string;
font_color?: string;
@ -69,9 +66,16 @@ const TextLeaf = (props: TextLeafProps) => {
if (leaf.href) {
newChildren = (
<TextLink getSelection={getSelection} title={leaf.text} href={leaf.href}>
<LinkInline
temporaryType={TemporaryType.Link}
getSelection={getSelection}
selectedText={leaf.text}
data={{
href: leaf.href,
}}
>
{newChildren}
</TextLink>
</LinkInline>
);
}
@ -85,7 +89,6 @@ const TextLeaf = (props: TextLeafProps) => {
isLast={isLast}
isFirst={text === parent.children[0]}
getSelection={getSelection}
formula={leaf.formula}
data={data}
temporaryType={temporaryType}
selectedText={leaf.text}
@ -100,21 +103,12 @@ const TextLeaf = (props: TextLeafProps) => {
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through',
leaf.selection_high_lighted && 'bg-content-blue-100',
leaf.link_selection_lighted && 'text-text-link-selector bg-content-blue-100',
leaf.code && !leaf.temporary && 'inline-code',
leaf.bold && 'font-bold',
leaf.italic && 'italic',
leaf.underline && 'underline',
].filter(Boolean);
if (leaf.link_placeholder && leaf.text) {
newChildren = (
<LinkHighLight leaf={leaf} title={leaf.link_placeholder}>
{newChildren}
</LinkHighLight>
);
}
if (leaf.temporary) {
newChildren = (
<TemporaryInput getSelection={getSelection} leaf={leaf}>

View File

@ -25,7 +25,6 @@ export function useEditor({
decorateSelection,
onKeyDown,
isCodeBlock,
linkDecorateSelection,
temporarySelection,
}: EditorProps) {
const { editor } = useSlateYjs({ delta });
@ -97,10 +96,6 @@ export function useEditor({
getDecorateRange(path, decorateSelection, {
selection_high_lighted: true,
}),
getDecorateRange(path, linkDecorateSelection?.selection, {
link_selection_lighted: true,
link_placeholder: linkDecorateSelection?.placeholder,
}),
getDecorateRange(path, temporarySelection, {
temporary: true,
}),
@ -108,7 +103,7 @@ export function useEditor({
return ranges;
},
[temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
[temporarySelection, decorateSelection, getDecorateRange]
);
const onKeyDownRewrite = useCallback(

View File

@ -1,13 +0,0 @@
import { useAppSelector } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { TEXT_LINK_NAME } from '$app/constants/document/name';
export function useSubscribeLinkPopover() {
const { docId } = useSubscribeDocument();
const linkPopover = useAppSelector((state) => {
return state[TEXT_LINK_NAME][docId];
});
return linkPopover;
}

View File

@ -18,19 +18,8 @@ export function useSubscribeDecorate(id: string) {
return temporary.selection;
});
const linkDecorateSelection = useAppSelector((state) => {
const linkPopoverState = state[TEXT_LINK_NAME][docId];
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
return {
selection: linkPopoverState.selection,
placeholder: linkPopoverState.title,
};
});
return {
decorateSelection,
linkDecorateSelection,
temporarySelection,
};
}

View File

@ -0,0 +1,125 @@
import React, { useCallback, useMemo, useRef } from 'react';
import TextField from '@mui/material/TextField';
import { IconButton } from '@mui/material';
import { LinkOff, OpenInNew } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import CopyIcon from '@mui/icons-material/CopyAll';
import { copyText } from '$app/utils/document/copy_paste';
import { open } from '@tauri-apps/api/shell';
function LinkEditContent({
value,
onChange,
onConfirm,
}: {
value: {
href?: string;
text?: string;
};
onChange: (val: { href: string; text: string }) => void;
onConfirm: () => void;
}) {
const valueRef = useRef<{
href?: string;
text?: string;
}>(value);
const { t } = useTranslation();
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onConfirm();
}
},
[onConfirm]
);
const operations = useMemo(
() => [
{
icon: <OpenInNew />,
tooltip: t('document.inlineLink.openInNewTab'),
onClick: () => {
void open(valueRef.current.href || '');
},
},
{
icon: <CopyIcon />,
tooltip: t('document.inlineLink.copyLink'),
onClick: () => {
void copyText(valueRef.current.href || '');
},
},
{
icon: <LinkOff />,
tooltip: t('document.inlineLink.removeLink'),
onClick: () => {
onChange({
href: '',
text: valueRef.current.text || '',
});
onConfirm();
},
},
],
[onChange, t, onConfirm]
);
return (
<div className={'flex w-[420px] flex-col items-end p-4'}>
<div className={'flex w-full items-center justify-end'}>
{operations.map((operation, index) => (
<Tooltip placement={'top'} key={index} title={operation.tooltip}>
<div className={'ml-2 cursor-pointer rounded border border-line-divider'}>
<IconButton onClick={operation.onClick}>{operation.icon}</IconButton>
</div>
</Tooltip>
))}
</div>
<div className={'flex h-[150px] w-full flex-col justify-between'}>
<TextField
autoFocus
placeholder={t('document.inlineLink.url.placeholder')}
label={t('document.inlineLink.url.label')}
onKeyDown={onKeyDown}
variant='standard'
value={value.href}
onChange={(e) => {
const newVal = e.target.value;
if (newVal === value.href) return;
onChange({
text: value.text || '',
href: newVal,
});
}}
/>
<TextField
placeholder={t('document.inlineLink.title.placeholder')}
label={t('document.inlineLink.title.label')}
onKeyDown={onKeyDown}
variant='standard'
value={value.text}
onChange={(e) => {
const newVal = e.target.value;
if (newVal === value.text) return;
onChange({
text: newVal,
href: value.href || '',
});
}}
/>
<div className={'flex w-full items-center justify-end'}>
<Button onClick={onConfirm} color='primary'>
{t('button.save')}
</Button>
</div>
</div>
</div>
);
}
export default LinkEditContent;

View File

@ -1,4 +1,3 @@
import React, { useRef } from 'react';
import { Functions } from '@mui/icons-material';
import KatexMath from '$app/components/document/_shared/KatexMath';

View File

@ -0,0 +1,20 @@
import React from 'react';
import { AddLinkOutlined } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
function TemporaryLink({ href = '', text = '' }: { href?: string; text?: string }) {
const { t } = useTranslation();
return (
<span className={'bg-content-blue-100'} contentEditable={false}>
{text ? (
<span className={'text-text-link-default underline'}>{text}</span>
) : (
<span className={'text-text-caption'}>
<AddLinkOutlined /> {t('document.inlineLink.title.label')}
</span>
)}
</span>
);
}
export default TemporaryLink;

View File

@ -8,6 +8,7 @@ import { formatTemporary } from '$app_reducers/document/async-actions/temporary'
import { useAppDispatch } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
import LinkEditContent from '$app/components/document/_shared/TemporaryInput/LinkEditContent';
const AFTER_RENDER_DELAY = 100;
@ -97,7 +98,7 @@ function TemporaryPopover() {
case TemporaryType.Equation:
return (
<EquationEditContent
value={data.latex}
value={data.latex || ''}
onChange={(latex: string) =>
onChangeData({
latex,
@ -106,6 +107,17 @@ function TemporaryPopover() {
onConfirm={onConfirm}
/>
);
case TemporaryType.Link:
return (
<LinkEditContent
value={{
href: data.href || '',
text: data.text || '',
}}
onChange={(val: { href: string; text: string }) => onChangeData(val)}
onConfirm={onConfirm}
/>
);
}
}, [onChangeData, onConfirm, temporaryState]);

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
@ -6,6 +6,7 @@ import { PopoverPosition } from '@mui/material';
import { useAppDispatch } from '$app/stores/store';
import { temporaryActions } from '$app_reducers/document/temporary_slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import TemporaryLink from '$app/components/document/_shared/TemporaryInput/TemporaryLink';
function TemporaryInput({
leaf,
@ -21,7 +22,9 @@ function TemporaryInput({
const dispatch = useAppDispatch();
const ref = useRef<HTMLSpanElement>(null);
const { docId } = useSubscribeDocument();
const match = useMemo(() => {
const [match, setMatch] = useState(false);
const getMatch = useCallback(() => {
if (!ref.current) return false;
if (!leaf.text) return false;
if (!temporaryState) return false;
@ -29,6 +32,7 @@ function TemporaryInput({
const selection = getSelection(ref.current);
if (!selection) return false;
return leaf.text === selectedText || selection.index <= temporaryState.selection.index;
}, [leaf.text, temporaryState, getSelection]);
@ -38,7 +42,9 @@ function TemporaryInput({
switch (type) {
case TemporaryType.Equation:
return <TemporaryEquation latex={data.latex} />;
return <TemporaryEquation latex={data.latex || ''} />;
case TemporaryType.Link:
return <TemporaryLink {...data} />;
default:
return null;
}
@ -69,6 +75,11 @@ function TemporaryInput({
});
}, [dispatch, docId, id, match, setAnchorPosition]);
useEffect(() => {
const match = getMatch();
setMatch(match);
}, [getMatch]);
return (
<span ref={ref}>
{match ? renderPlaceholder() : null}

View File

@ -1,39 +0,0 @@
import React, { useEffect, useState } from 'react';
import TextField from '@mui/material/TextField';
function EditLink({
autoFocus,
text,
value,
onChange,
}: {
autoFocus?: boolean;
text: string;
value: string;
onChange?: (newValue: string) => void;
}) {
const [val, setVal] = useState(value);
useEffect(() => {
onChange?.(val);
}, [val, onChange]);
return (
<div className={'mb-2 w-[100%] text-sm'}>
<TextField
className={'w-[100%]'}
label={text}
autoFocus={autoFocus}
variant='standard'
onChange={(e) => {
const newValue = e.target.value;
setVal(newValue);
}}
value={val}
/>
</div>
);
}
export default EditLink;

View File

@ -1,96 +0,0 @@
import React, { useEffect, useRef } from 'react';
import BlockPortal from '$app/components/document/BlockPortal';
import { getNode } from '$app/utils/document/node';
import LanguageIcon from '@mui/icons-material/Language';
import CopyIcon from '@mui/icons-material/CopyAll';
import { copyText } from '$app/utils/document/copy_paste';
import { useMessage } from '$app/components/document/_shared/Message';
import { useTranslation } from 'react-i18next';
const iconSize = {
width: '1rem',
height: '1rem',
};
function EditLinkToolbar({
blockId,
linkElement,
onMouseEnter,
onMouseLeave,
href,
editing,
onEdit,
}: {
blockId: string;
linkElement: HTMLAnchorElement;
href: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
editing: boolean;
onEdit: () => void;
}) {
const { t } = useTranslation();
const { show, contentHolder } = useMessage();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const toolbarDom = ref.current;
if (!toolbarDom) return;
const linkRect = linkElement.getBoundingClientRect();
const node = getNode(blockId);
if (!node) return;
const nodeRect = node.getBoundingClientRect();
const top = linkRect.top - nodeRect.top + linkRect.height + 4;
const left = linkRect.left - nodeRect.left;
toolbarDom.style.top = `${top}px`;
toolbarDom.style.left = `${left}px`;
toolbarDom.style.opacity = '1';
});
return (
<>
{editing && (
<BlockPortal blockId={blockId}>
<div
ref={ref}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
opacity: 0,
}}
className='absolute z-10 inline-flex h-[32px] min-w-[200px] max-w-[400px] items-stretch overflow-hidden rounded-[8px] bg-bg-body leading-tight text-text-title shadow-md transition-opacity duration-100'
>
<div className={'flex w-[100%] items-center justify-between px-2 text-[75%]'}>
<div className={'mr-2'}>
<LanguageIcon sx={iconSize} />
</div>
<div className={'mr-2 flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{href}</div>
<div
onClick={async () => {
try {
await copyText(href);
show({ message: t('message.copy.success'), duration: 6000 });
} catch {
show({ message: t('message.copy.fail'), duration: 6000 });
}
}}
className={'mr-2 cursor-pointer'}
>
<CopyIcon sx={iconSize} />
</div>
<div onClick={onEdit} className={'cursor-pointer'}>
{t('button.edit')}
</div>
</div>
</div>
</BlockPortal>
)}
{contentHolder}
</>
);
}
export default EditLinkToolbar;

View File

@ -1,135 +0,0 @@
import React, { useCallback } from 'react';
import Popover from '@mui/material/Popover';
import { DeleteOutline, Done } from '@mui/icons-material';
import EditLink from '$app/components/document/_shared/TextLink/EditLink';
import { useAppDispatch } from '$app/stores/store';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeLinkPopover } from '$app/components/document/_shared/SubscribeLinkPopover.hooks';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
function LinkEditPopover() {
const dispatch = useAppDispatch();
const { docId, controller } = useSubscribeDocument();
const { t } = useTranslation();
const popoverState = useSubscribeLinkPopover();
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
const onClose = useCallback(() => {
dispatch(linkPopoverActions.closeLinkPopover(docId));
}, [dispatch, docId]);
const onExited = useCallback(() => {
if (!id || !selection) return;
const newSelection = {
index: selection.index,
length: title.length,
};
dispatch(
rangeActions.setRange({
docId,
id,
rangeStatic: newSelection,
})
);
dispatch(
rangeActions.setCaret({
docId,
caret: {
id,
...newSelection,
},
})
);
}, [docId, id, selection, title, dispatch]);
const onChange = useCallback(
(newVal: { href?: string; title: string }) => {
if (!id) return;
if (newVal.title === title && newVal.href === href) return;
dispatch(
linkPopoverActions.updateLinkPopover({
docId,
linkState: {
id,
href: newVal.href,
title: newVal.title,
},
})
);
},
[docId, dispatch, href, id, title]
);
const onDone = useCallback(async () => {
if (!controller) return;
await dispatch(
formatLinkThunk({
controller,
})
);
onClose();
}, [controller, dispatch, onClose]);
return (
<Popover
onMouseDown={(e) => e.stopPropagation()}
open={open}
disableAutoFocus={true}
anchorReference='anchorPosition'
anchorPosition={anchorPosition}
TransitionProps={{
onExited,
}}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
PaperProps={{
sx: {
width: 500,
},
}}
>
<div className='flex flex-col p-3'>
<EditLink
text={t('document.inlineLink.url.label')}
value={href}
onChange={(link) => {
onChange({
href: link,
title,
});
}}
/>
<EditLink
text={t('document.inlineLink.title.label')}
value={title}
onChange={(text) =>
onChange({
href,
title: text,
})
}
/>
<div className={'flex items-center justify-end'}>
<Button onClick={onDone}>
<Done />
{t('button.done')}
</Button>
</div>
</div>
</Popover>
);
}
export default LinkEditPopover;

View File

@ -1,16 +0,0 @@
import React from 'react';
import { isOverlappingPrefix } from '$app/utils/document/temporary';
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
return (
<>
{leaf.text === title || isOverlappingPrefix(leaf.text, title) ? (
<span contentEditable={false}>{title}</span>
) : null}
<span className={'absolute opacity-0'}>{children}</span>
</>
);
}
export default LinkHighLight;

View File

@ -1,27 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { debounce } from '$app/utils/tool';
export function useTextLink(id: string) {
const [editing, setEditing] = useState(false);
const ref = useRef<HTMLAnchorElement | null>(null);
const show = useMemo(() => debounce(() => setEditing(true), 500), []);
const hide = useMemo(() => debounce(() => setEditing(false), 500), []);
const onMouseEnter = useCallback(() => {
hide.cancel();
show();
}, [hide, show]);
const onMouseLeave = useCallback(() => {
show.cancel();
hide();
}, [hide, show]);
return {
editing,
onMouseEnter,
onMouseLeave,
ref,
};
}

View File

@ -1,84 +0,0 @@
import React, { useCallback, useContext } from 'react';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.hooks';
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
import { useAppDispatch } from '$app/stores/store';
import { linkPopoverActions } from '$app_reducers/document/slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
function TextLink({
getSelection,
title,
href,
children,
}: {
getSelection: (node: Element) => {
index: number;
length: number;
} | null;
children: React.ReactNode;
href: string;
title: string;
}) {
const blockId = useContext(NodeIdContext);
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
const dispatch = useAppDispatch();
const { docId } = useSubscribeDocument();
const onEdit = useCallback(() => {
if (!ref.current) return;
const selection = getSelection(ref.current);
if (!selection) return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
dispatch(
linkPopoverActions.setLinkPopover({
docId,
linkState: {
anchorPosition: {
top: rect.top + rect.height,
left: rect.left + rect.width / 2,
},
id: blockId,
selection,
title,
href,
open: true,
},
})
);
}, [blockId, dispatch, docId, getSelection, href, ref, title]);
if (!blockId) return null;
return (
<>
<a
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
ref={ref}
href={href}
target='_blank'
rel='noopener noreferrer'
className='cursor-pointer text-text-link-default'
>
<span className={' border-b-[1px] border-b-text-link-default '}>{children}</span>
</a>
{ref.current && (
<EditLinkToolbar
editing={editing}
href={href}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
linkElement={ref.current}
blockId={blockId}
onEdit={onEdit}
/>
)}
</>
);
}
export default TextLink;

View File

@ -1,18 +1,17 @@
import { useAppDispatch } from '$app/stores/store';
import { useAppSelector } from "$app/stores/store";
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { useParams, useLocation } from 'react-router-dom';
import { Page, pagesActions } from '$app_reducers/pages/slice';
import { Log } from '$app/utils/log';
import { Page } from '$app_reducers/pages/slice';
import { useTranslation } from 'react-i18next';
import { PageController } from "$app/stores/effects/workspace/page/page_controller";
export function useLoadExpandedPages() {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const params = useParams();
const location = useLocation();
const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
const currentPageId = params.id;
const pageMap = useAppSelector((state) => state.pages.pageMap);
const [pagePath, setPagePath] = useState<
(
| Page
@ -22,37 +21,39 @@ export function useLoadExpandedPages() {
)[]
>([]);
const loadPage = useCallback(
async (id: string) => {
if (!id) return;
const controller = new PageController(id);
const loadPagePath = useCallback(
async (pageId: string) => {
let page = pageMap[pageId];
const controller = new PageController(pageId);
if (!page) {
try {
page = await controller.getPage();
} catch (e) {
// do nothing
}
try {
const page = await controller.getPage();
const childPages = await controller.getChildPages();
dispatch(pagesActions.addChildPages({ id, childPages }));
dispatch(pagesActions.expandPage(id));
setPagePath((prev) => [page, ...prev]);
await loadPage(page.parentId);
} catch (e) {
Log.info(`${id} is workspace`);
if (!page) {
return;
}
}
},
[dispatch]
);
setPagePath(prev => {
return [
page,
...prev
]
});
await loadPagePath(page.parentId);
}, [pageMap]);
useEffect(() => {
setPagePath([]);
if (!currentPageId) {
return;
}
void (async () => {
await loadPage(currentPageId);
})();
}, [currentPageId, dispatch, loadPage]);
loadPagePath(currentPageId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageId]);
useEffect(() => {
if (isTrash) {

View File

@ -5,12 +5,11 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { ViewLayoutPB } from '@/services/backend';
import { useNavigate, useParams } from 'react-router-dom';
import { pageTypeMap } from '$app/constants';
import { useTranslation } from 'react-i18next';
import { updatePageName } from '$app_reducers/pages/async_actions';
export function useLoadChildPages(pageId: string) {
const dispatch = useAppDispatch();
const childPages = useAppSelector((state) => state.pages.relationMap[pageId]);
const collapsed = useAppSelector((state) => !state.pages.expandedIdMap[pageId]);
const toggleCollapsed = useCallback(() => {
if (collapsed) {
@ -24,31 +23,21 @@ export function useLoadChildPages(pageId: string) {
return new PageController(pageId);
}, [pageId]);
const onChildPagesChanged = useCallback(
(childPages: Page[]) => {
const onPageChanged = useCallback(
(page: Page, children: Page[]) => {
dispatch(pagesActions.onPageChanged(page));
dispatch(
pagesActions.addChildPages({
id: pageId,
childPages,
id: page.id,
childPages: children,
})
);
},
[dispatch, pageId]
);
const onPageChanged = useCallback(
(page: Page) => {
dispatch(pagesActions.onPageChanged(page));
},
[dispatch]
);
const onPageCollapsed = useCallback(async () => {
dispatch(pagesActions.removeChildPages(pageId));
await controller.unsubscribe();
}, [dispatch, pageId, controller]);
const onPageExpanded = useCallback(async () => {
const loadPageChildren = useCallback(async (pageId: string) => {
const childPages = await controller.getChildPages();
dispatch(
@ -57,25 +46,22 @@ export function useLoadChildPages(pageId: string) {
childPages,
})
);
await controller.subscribe({
onChildPagesChanged,
}, [controller, dispatch]);
useEffect(() => {
void loadPageChildren(pageId);
}, [loadPageChildren, pageId]);
useEffect(() => {
controller.subscribe({
onPageChanged,
});
}, [controller, dispatch, onChildPagesChanged, onPageChanged, pageId]);
useEffect(() => {
if (collapsed) {
onPageCollapsed();
} else {
onPageExpanded();
}
}, [collapsed, onPageCollapsed, onPageExpanded]);
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
}, [controller, onPageChanged]);
return {
toggleCollapsed,
@ -86,7 +72,6 @@ export function useLoadChildPages(pageId: string) {
export function usePageActions(pageId: string) {
const page = useAppSelector((state) => state.pages.pageMap[pageId]);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const controller = useMemo(() => {
@ -103,7 +88,7 @@ export function usePageActions(pageId: string) {
async (layout: ViewLayoutPB) => {
const newViewId = await controller.createPage({
layout,
name: t('document.title.placeholder'),
name: ""
});
dispatch(pagesActions.expandPage(pageId));
@ -111,7 +96,7 @@ export function usePageActions(pageId: string) {
navigate(`/page/${pageType}/${newViewId}`);
},
[t, controller, dispatch, navigate, pageId]
[controller, dispatch, navigate, pageId]
);
const onDeletePage = useCallback(async () => {
@ -124,12 +109,9 @@ export function usePageActions(pageId: string) {
const onRenamePage = useCallback(
async (name: string) => {
await controller.updatePage({
id: pageId,
name,
});
await dispatch(updatePageName({ id: pageId, name }));
},
[controller, pageId]
[dispatch, pageId]
);
useEffect(() => {
@ -152,3 +134,4 @@ export function useSelectedPage(pageId: string) {
return id === pageId;
}

View File

@ -6,6 +6,7 @@ import AddButton from './AddButton';
import MoreButton from './MoreButton';
import { ViewLayoutPB } from '@/services/backend';
import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks';
import { useTranslation } from 'react-i18next';
function NestedPageTitle({
pageId,
@ -26,6 +27,7 @@ function NestedPageTitle({
onDuplicate: () => Promise<void>;
onRename: (newName: string) => Promise<void>;
}) {
const { t } = useTranslation();
const page = useAppSelector((state) => {
return state.pages.pageMap[pageId];
});
@ -47,7 +49,7 @@ function NestedPageTitle({
toggleCollapsed();
}}
style={{
transform: collapsed ? 'rotate(0deg)' : 'rotate(-90deg)',
transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
}}
className={'flex h-[100%] w-8 items-center justify-center p-2'}
>
@ -55,7 +57,11 @@ function NestedPageTitle({
<ArrowRightSvg />
</div>
</button>
<div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{page.name}</div>
{page.icon ? <div className={'mr-1 h-5 w-5'}>{page.icon.value}</div> : null}
<div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>
{page.name || t('menuAppHeader.defaultNewPageName')}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}>
<AddButton isVisible={isHovering} onAddPage={onAddPage} />

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog';
@ -21,6 +21,10 @@ function RenameDialog({
const [value, setValue] = useState(defaultValue);
const [error, setError] = useState(false);
useEffect(() => {
setValue(defaultValue);
setError(false);
}, [defaultValue]);
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
@ -37,6 +41,7 @@ function RenameDialog({
variant='standard'
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('button.Cancel')}</Button>
<Button

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useMemo } from 'react';
import Collapse from '@mui/material/Collapse';
import { TransitionGroup } from 'react-transition-group';
import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
@ -10,6 +10,10 @@ function NestedPage({ pageId }: { pageId: string }) {
const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
const children = useMemo(() => {
return collapsed ? [] : childPages;
}, [collapsed, childPages]);
return (
<BlockDraggable id={pageId} type={BlockDraggableType.PAGE} data-page-id={pageId}>
<NestedPageTitle
@ -27,7 +31,7 @@ function NestedPage({ pageId }: { pageId: string }) {
<div className={'pl-4 pt-[2px]'}>
<TransitionGroup>
{childPages?.map((pageId) => (
{children?.map((pageId) => (
<Collapse key={pageId}>
<NestedPage key={pageId} pageId={pageId} />
</Collapse>

View File

@ -16,6 +16,7 @@ function AppearanceSetting({
const { t } = useTranslation();
useEffect(() => {
const html = document.documentElement;
html?.setAttribute('data-dark-mode', String(themeMode === ThemeMode.Dark));

View File

@ -21,7 +21,7 @@ function NewPageButton({ workspaceId }: { workspaceId: string }) {
<button
onClick={async () => {
const { id } = await controller.createView({
name: t('document.title.placeholder'),
name: "",
layout: ViewLayoutPB.Document,
parent_view_id: workspaceId,
});

View File

@ -63,17 +63,6 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
return new WorkspaceController(id);
}, [id]);
const onWorkspaceChanged = useCallback(
(data: WorkspaceItem) => {
dispatch(workspaceActions.onWorkspaceChanged(data));
},
[dispatch]
);
const onWorkspaceDeleted = useCallback(() => {
dispatch(workspaceActions.onWorkspaceDeleted(id));
}, [dispatch, id]);
const openWorkspace = useCallback(async () => {
await controller.open();
}, [controller]);
@ -96,7 +85,6 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
const initializeWorkspace = useCallback(async () => {
const childPages = await controller.getChildPages();
dispatch(
pagesActions.addChildPages({
id,
@ -107,11 +95,9 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
const subscribeToWorkspace = useCallback(async () => {
await controller.subscribe({
onWorkspaceChanged,
onWorkspaceDeleted,
onChildPagesChanged,
});
}, [controller, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
}, [controller, onChildPagesChanged]);
useEffect(() => {
void (async () => {

View File

@ -82,10 +82,13 @@ export interface ImageBlockData {
align: Align;
}
export enum CoverType {
Image = 'image',
Color = 'color',
}
export interface PageBlockData extends TextBlockData {
cover?: string;
icon?: string;
coverType?: 'image' | 'color';
coverType?: CoverType;
}
export type BlockData<Type> = Type extends BlockType.HeadingBlock
@ -303,10 +306,6 @@ export interface EditorProps {
value?: Delta;
selection?: RangeStaticNoId;
decorateSelection?: RangeStaticNoId;
linkDecorateSelection?: {
selection?: RangeStaticNoId;
placeholder?: string;
};
temporarySelection?: RangeStaticNoId;
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
@ -319,15 +318,6 @@ export interface BlockCopyData {
html: string;
}
export interface LinkPopoverState {
anchorPosition?: { top: number; left: number };
id?: string;
selection?: RangeStaticNoId;
open?: boolean;
href?: string;
title?: string;
}
export interface TemporaryState {
id: string;
type: TemporaryType;
@ -339,10 +329,11 @@ export interface TemporaryState {
export enum TemporaryType {
Equation = 'equation',
Link = 'link',
}
export type TemporaryData = InlineEquationData;
export interface InlineEquationData {
latex: string;
export interface TemporaryData {
latex?: string;
href?: string;
text?: string;
}

View File

@ -1,5 +1,6 @@
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
import { AppearanceSettingsPB, ThemeModePB } from '@/services/backend';
import { AppearanceSettingsPB } from '@/services/backend';
import { Theme, ThemeMode, UserSetting } from '$app/interfaces';
export class UserSettingController {
private readonly backendService: UserBackendService;
@ -17,11 +18,25 @@ export class UserSettingController {
return {};
};
getAppearanceSetting = async (): Promise<AppearanceSettingsPB | undefined> => {
getAppearanceSetting = async (): Promise<Partial<UserSetting> | undefined> => {
const appearanceSetting = await this.backendService.getAppearanceSettings();
if (appearanceSetting.ok) {
return appearanceSetting.val;
const res = appearanceSetting.val;
const { locale, theme = Theme.Default, theme_mode = ThemeMode.Light } = res;
let language = 'en';
if (locale.language_code && locale.country_code) {
language = `${locale.language_code}-${locale.country_code}`;
} else if (locale.language_code) {
language = locale.language_code;
}
return {
themeMode: theme_mode,
theme: theme as Theme,
language: language,
};
}
return;

View File

@ -14,8 +14,11 @@ import {
ImportPB,
MoveNestedViewPayloadPB,
FolderEventMoveNestedView,
ViewIconPB,
UpdateViewIconPayloadPB,
FolderEventUpdateViewIcon,
} from '@/services/backend/events/flowy-folder2';
import { Page } from '$app_reducers/pages/slice';
import { Page, PageIcon } from '$app_reducers/pages/slice';
export class PageBackendService {
constructor() {
@ -54,17 +57,23 @@ export class PageBackendService {
payload.name = page.name;
}
if (page.cover !== undefined) {
payload.cover_url = page.cover;
}
if (page.icon !== undefined) {
payload.icon_url = page.icon;
}
return FolderEventUpdateView(payload);
};
updatePageIcon = async (viewId: string, icon?: PageIcon) => {
const payload = new UpdateViewIconPayloadPB({
view_id: viewId,
icon: icon
? new ViewIconPB({
ty: icon.ty,
value: icon.value,
})
: undefined,
});
return FolderEventUpdateViewIcon(payload);
};
deletePage = async (viewId: string) => {
const payload = new RepeatedViewIdPB({
items: [viewId],

View File

@ -1,14 +1,12 @@
import { ViewLayoutPB } from '@/services/backend';
import { ViewLayoutPB, ViewPB } from '@/services/backend';
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
import { Page, PageIcon, parserViewPBToPage } from '$app_reducers/pages/slice';
export class PageController {
private readonly backendService: PageBackendService = new PageBackendService();
private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private onChangeQueue?: AsyncQueue;
constructor(private readonly id: string) {
//
}
@ -72,22 +70,15 @@ export class PageController {
return this.getPage(parentPageId);
};
subscribe = async (callbacks: {
onChildPagesChanged?: (childPages: Page[]) => void;
onPageChanged?: (page: Page) => void;
}) => {
const onChanged = async () => {
const page = await this.getPage();
const childPages = await this.getChildPages();
callbacks.onPageChanged?.(page);
callbacks.onChildPagesChanged?.(childPages);
subscribe = async (callbacks: { onPageChanged?: (page: Page, children: Page[]) => void }) => {
const didUpdateView = (payload: Uint8Array) => {
const res = ViewPB.deserializeBinary(payload);
const page = parserViewPBToPage(ViewPB.deserializeBinary(payload));
const childPages = res.child_views.map(parserViewPBToPage);
callbacks.onPageChanged?.(page, childPages);
};
this.onChangeQueue = new AsyncQueue(onChanged);
await this.observer.subscribeView(this.id, {
didUpdateChildViews: this.didUpdateChildPages,
didUpdateView: this.didUpdateView,
didUpdateView,
});
};
@ -105,6 +96,16 @@ export class PageController {
return Promise.reject(result.err);
};
updatePageIcon = async (icon?: PageIcon) => {
const result = await this.backendService.updatePageIcon(this.id, icon);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
deletePage = async () => {
const result = await this.backendService.deletePage(this.id);
@ -125,12 +126,4 @@ export class PageController {
return Promise.reject(result.err);
};
private didUpdateChildPages = (payload: Uint8Array) => {
this.onChangeQueue?.enqueue(Math.random());
};
private didUpdateView = (payload: Uint8Array) => {
this.onChangeQueue?.enqueue(Math.random());
};
}

View File

@ -1,18 +1,13 @@
import { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { CreateViewPayloadPB } from '@/services/backend';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { CreateViewPayloadPB, RepeatedViewPB } from "@/services/backend";
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
export class WorkspaceController {
private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private readonly pageBackendService: PageBackendService;
private readonly backendService: WorkspaceBackendService;
private onWorkspaceChanged?: (data: WorkspaceItem) => void;
private onWorkspaceDeleted?: () => void;
private onChangeQueue?: AsyncQueue;
constructor(private readonly workspaceId: string) {
this.pageBackendService = new PageBackendService();
this.backendService = new WorkspaceBackendService();
@ -43,23 +38,15 @@ export class WorkspaceController {
};
subscribe = async (callbacks: {
onWorkspaceChanged?: (data: WorkspaceItem) => void;
onWorkspaceDeleted?: () => void;
onChildPagesChanged?: (childPages: Page[]) => void;
}) => {
this.onWorkspaceChanged = callbacks.onWorkspaceChanged;
this.onWorkspaceDeleted = callbacks.onWorkspaceDeleted;
const onChildPagesChanged = async () => {
const childPages = await this.getChildPages();
callbacks.onChildPagesChanged?.(childPages);
};
this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
const didUpdateWorkspace = (payload: Uint8Array) => {
const res = RepeatedViewPB.deserializeBinary(payload).items;
callbacks.onChildPagesChanged?.(res.map(parserViewPBToPage));
}
await this.observer.subscribeWorkspace(this.workspaceId, {
didUpdateWorkspace: this.didUpdateWorkspace,
didDeleteWorkspace: this.didDeleteWorkspace,
didUpdateChildViews: this.didUpdateChildPages,
didUpdateWorkspace
});
};
@ -85,15 +72,5 @@ export class WorkspaceController {
return [];
};
private didUpdateWorkspace = (payload: Uint8Array) => {
// this.onWorkspaceChanged?.(payload.toObject());
};
private didDeleteWorkspace = (payload: Uint8Array) => {
this.onWorkspaceDeleted?.();
};
private didUpdateChildPages = (payload: Uint8Array) => {
this.onChangeQueue?.enqueue(Math.random());
};
}

View File

@ -26,22 +26,22 @@ export class WorkspaceObserver {
subscribeWorkspace = async (
workspaceId: string,
callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void;
didUpdateWorkspace: (payload: Uint8Array) => void;
didDeleteWorkspace: (payload: Uint8Array) => void;
didUpdateChildViews?: (payload: Uint8Array) => void;
didUpdateWorkspace?: (payload: Uint8Array) => void;
didDeleteWorkspace?: (payload: Uint8Array) => void;
}
) => {
this.listener = new WorkspaceNotificationObserver({
id: workspaceId,
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateWorkspace:
case FolderNotification.DidUpdateWorkspaceViews:
if (!result.ok) break;
callbacks.didUpdateWorkspace(result.val);
callbacks.didUpdateWorkspace?.(result.val);
break;
case FolderNotification.DidUpdateChildViews:
if (!result.ok) break;
callbacks.didUpdateChildViews(result.val);
callbacks.didUpdateChildViews?.(result.val);
break;
// case FolderNotification.DidDeleteWorkspace:
// if (!result.ok) break;
@ -58,8 +58,8 @@ export class WorkspaceObserver {
subscribeView = async (
viewId: string,
callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void;
didUpdateView: (payload: Uint8Array) => void;
didUpdateChildViews?: (payload: Uint8Array) => void;
didUpdateView?: (payload: Uint8Array) => void;
}
) => {
this.listener = new WorkspaceNotificationObserver({
@ -68,11 +68,11 @@ export class WorkspaceObserver {
switch (notification) {
case FolderNotification.DidUpdateChildViews:
if (!result.ok) break;
callbacks.didUpdateChildViews(result.val);
callbacks.didUpdateChildViews?.(result.val);
break;
case FolderNotification.DidUpdateView:
if (!result.ok) break;
callbacks.didUpdateView(result.val);
callbacks.didUpdateView?.(result.val);
break;
default:
break;

View File

@ -4,17 +4,33 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta';
import { RootState } from '$app/stores/store';
import { DOCUMENT_NAME } from '$app/constants/document/name';
import { updatePageName } from '$app_reducers/pages/async_actions';
import { getDeltaText } from '$app/utils/document/delta';
export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta',
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
const { id, delta, controller } = payload;
const { getState } = thunkAPI;
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
const oldDelta = new Delta(node.data.delta);
const newDelta = new Delta(delta);
// If the node is the root node, update the page name
if (!node.parent) {
await dispatch(
updatePageName({
id: docId,
name: getDeltaText(newDelta),
})
);
return;
}
const diffDelta = newDelta.diff(oldDelta);
if (diffDelta.ops.length === 0) return;

View File

@ -2,4 +2,3 @@ export * from './blocks';
export * from './turn_to';
export * from './keydown';
export * from './range';
export * from './link';

View File

@ -1,7 +1,7 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { createAsyncThunk } from "@reduxjs/toolkit";
import { DocumentController } from "$app/stores/effects/document/document_controller";
import { BlockType, RangeStatic, SplitRelationship } from "$app/interfaces/document";
import { turnToTextBlockThunk } from "$app_reducers/document/async-actions/turn_to";
import {
findNextHasDeltaNode,
findPrevHasDeltaNode,
@ -9,23 +9,27 @@ import {
getLeftCaretByRange,
getRightCaretByRange,
transformToNextLineCaret,
transformToPrevLineCaret,
} from '$app/utils/document/action';
import Delta from 'quill-delta';
import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
import { rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
import { blockConfig } from '$app/constants/document/config';
import { Keyboard } from '$app/constants/document/keyboard';
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
import { getPreviousWordIndex } from '$app/utils/document/delta';
transformToPrevLineCaret
} from "$app/utils/document/action";
import Delta from "quill-delta";
import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from "$app_reducers/document/async-actions/blocks";
import { rangeActions } from "$app_reducers/document/slice";
import { RootState } from "$app/stores/store";
import { blockConfig } from "$app/constants/document/config";
import { Keyboard } from "$app/constants/document/keyboard";
import { DOCUMENT_NAME, RANGE_NAME } from "$app/constants/document/name";
import { getDeltaText, getPreviousWordIndex } from "$app/utils/document/delta";
import { updatePageName } from "$app_reducers/pages/async_actions";
import { newBlock } from "$app/utils/document/block";
/**
* Delete a block by backspace or delete key
* 1. If the block is not a text block, turn it to a text block
* 2. If the block is a text block
* 2.1 If the block has next node or is top level, merge it to the previous line
* 2.2 If the block has no next node and is not top level, outdent it
- Deletes a block using the backspace or delete key.
- If the block is not a text block, it is converted into a text block.
- If the block is a text block:
- - If the block is the first line, it is merged into the document title, and a new line is inserted.
- - If the block is not the first line and it has a next sibling, it is merged into the previous line (including the previous sibling and its parent).
- - If the block has no next sibling and is not a top-level block, it is outdented (moved to a higher level in the hierarchy).
*/
export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
'document/backspaceDeleteActionForBlock',
@ -49,11 +53,43 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
}
const isTopLevel = parent.type === BlockType.PageBlock;
const isFirstLine = isTopLevel && index === 0;
if (isTopLevel && isFirstLine) {
// merge to document title and insert a new line
const parentDelta = new Delta(parent.data.delta);
const caretIndex = parentDelta.length();
const caret = {
id: parent.id,
index: caretIndex,
length: 0,
};
const titleDelta = parentDelta.concat(new Delta(node.data.delta));
await dispatch(updatePageName({ id: docId, name: getDeltaText(titleDelta) }));
const actions = [
controller.getDeleteAction(node),
]
if (!nextNodeId) {
// insert a new line
const block = newBlock<any>(BlockType.TextBlock, parent.id, {
delta: [{ insert: "" }]
});
actions.push(controller.getInsertAction(block, null));
}
await controller.applyActions(actions);
dispatch(rangeActions.initialState(docId));
dispatch(
rangeActions.setCaret({
docId,
caret,
})
);
return;
}
if (isTopLevel || nextNodeId) {
// merge to previous line
const prevLine = findPrevHasDeltaNode(state, id);
if (!prevLine) return;
const caretIndex = new Delta(prevLine.data.delta).length();
const caret = {
@ -104,19 +140,49 @@ export const enterActionForBlockThunk = createAsyncThunk(
if (!node || !caret || caret.id !== id) return;
const delta = new Delta(node.data.delta);
const nodeDelta = delta.slice(0, caret.index);
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
const isDocumentTitle = !node.parent;
// update page title and insert a new line
if (isDocumentTitle) {
// update page title
await dispatch(updatePageName({
id: docId,
name: getDeltaText(nodeDelta),
}));
// insert a new line
const block = newBlock<any>(BlockType.TextBlock, node.id, {
delta: insertNodeDelta.ops,
});
const insertNodeAction = controller.getInsertAction(block, null);
await controller.applyActions([insertNodeAction]);
dispatch(rangeActions.initialState(docId));
dispatch(
rangeActions.setCaret({
docId,
caret: {
id: block.id,
index: 0,
length: 0,
},
})
);
return;
}
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
// If the node is not a text block, turn it to a text block
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
const nodeDelta = delta.slice(0, caret.index);
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
if (!insertNodeAction) return;
const updateNode = {
...node,
data: {

View File

@ -1,103 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import Delta from 'quill-delta';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
import { DOCUMENT_NAME, RANGE_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
export const formatLinkThunk = createAsyncThunk<
boolean,
{
controller: DocumentController;
}
>('document/formatLink', async (payload, thunkAPI) => {
const { controller } = payload;
const { getState } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState;
const documentState = state[DOCUMENT_NAME][docId];
const linkPopover = state[TEXT_LINK_NAME][docId];
if (!linkPopover) return false;
const { selection, id, href, title = '' } = linkPopover;
if (!selection || !id) return false;
const node = documentState.nodes[id];
const nodeDelta = new Delta(node.data?.delta);
const index = selection.index || 0;
const length = selection.length || 0;
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
if (href && !regex.test(href)) {
return false;
}
const diffDelta = new Delta().retain(index).delete(length).insert(title, {
href,
});
const newDelta = nodeDelta.compose(diffDelta);
const updateAction = controller.getUpdateAction({
...node,
data: {
...node.data,
delta: newDelta.ops,
},
});
await controller.applyActions([updateAction]);
return true;
});
export const newLinkThunk = createAsyncThunk<
void,
{
docId: string;
}
>('document/newLink', async ({ docId }, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const documentState = state[DOCUMENT_NAME][docId];
const documentRange = state[RANGE_NAME][docId];
const { caret } = documentRange;
if (!caret) return;
const { index, length, id } = caret;
const block = documentState.nodes[id];
const delta = new Delta(block.data.delta).slice(index, index + length);
const op = delta.ops.find((op) => op.attributes?.href);
const href = op?.attributes?.href as string;
const domSelection = window.getSelection();
if (!domSelection) return;
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
if (!domRange) return;
const title = domSelection.toString();
const { top, left, height, width } = domRange.getBoundingClientRect();
dispatch(rangeActions.initialState(docId));
dispatch(
linkPopoverActions.setLinkPopover({
docId,
linkState: {
anchorPosition: {
top: top + height,
left: left + width / 2,
},
id,
selection: {
index,
length,
},
title,
href,
open: true,
},
})
);
});

View File

@ -33,14 +33,13 @@ export const createTemporary = createAsyncThunk(
const rangeDelta = getDeltaByRange(nodeDelta, selection);
const text = getDeltaText(rangeDelta);
const data = newDataWithTemporaryType(type, text);
temporaryState = {
id,
selection,
selectedText: text,
type,
data: {
latex: text,
},
data,
};
}
@ -51,6 +50,22 @@ export const createTemporary = createAsyncThunk(
}
);
function newDataWithTemporaryType(type: TemporaryType, text: string) {
switch (type) {
case TemporaryType.Equation:
return {
latex: text,
};
case TemporaryType.Link:
return {
href: '',
text: text,
};
default:
return {};
}
}
export const formatTemporary = createAsyncThunk(
'document/temporary/format',
async (payload: { controller: DocumentController }, thunkAPI) => {
@ -69,7 +84,7 @@ export const formatTemporary = createAsyncThunk(
const nodeDelta = new Delta(node.data?.delta);
const { index, length } = selection;
const diffDelta: Delta = new Delta();
let newSelection;
let newSelection = selection;
switch (type) {
case TemporaryType.Equation: {
@ -91,6 +106,21 @@ export const formatTemporary = createAsyncThunk(
break;
}
case TemporaryType.Link: {
if (!data.text) return;
if (!data.href) {
diffDelta.retain(index).delete(length).insert(data.text);
} else {
diffDelta.retain(index).delete(length).insert(data.text, {
href: data.href,
});
}
newSelection = {
index: selection.index,
length: data.text.length,
};
break;
}
default:
break;

View File

@ -5,21 +5,15 @@ import {
SlashCommandState,
RangeState,
RangeStatic,
LinkPopoverState,
SlashCommandOption,
} from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe';
import { temporarySlice } from '$app_reducers/document/temporary_slice';
import {
DOCUMENT_NAME,
RANGE_NAME,
RECT_RANGE_NAME,
SLASH_COMMAND_NAME,
TEXT_LINK_NAME,
} from '$app/constants/document/name';
import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '$app/constants/document/name';
import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
import { Op } from 'quill-delta';
const initialState: Record<string, DocumentState> = {};
@ -29,8 +23,6 @@ const rangeInitialState: Record<string, RangeState> = {};
const slashCommandInitialState: Record<string, SlashCommandState> = {};
const linkPopoverState: Record<string, LinkPopoverState> = {};
export const documentSlice = createSlice({
name: DOCUMENT_NAME,
initialState: initialState,
@ -68,6 +60,22 @@ export const documentSlice = createSlice({
children,
};
},
updateRootNodeDelta: (
state,
action: PayloadAction<{
docId: string;
rootId: string;
delta: Op[];
}>
) => {
const { docId, delta, rootId } = action.payload;
const documentState = state[docId];
if (!documentState) return;
const rootNode = documentState.nodes[rootId];
if (!rootNode) return;
rootNode.data.delta = delta;
},
/**
This function listens for changes in the data layer triggered by the data API,
and updates the UI state accordingly.
@ -371,63 +379,11 @@ export const slashCommandSlice = createSlice({
},
});
export const linkPopoverSlice = createSlice({
name: TEXT_LINK_NAME,
initialState: linkPopoverState,
reducers: {
initialState: (state, action: PayloadAction<string>) => {
const docId = action.payload;
state[docId] = {
open: false,
};
},
clear: (state, action: PayloadAction<string>) => {
const docId = action.payload;
delete state[docId];
},
setLinkPopover: (
state,
action: PayloadAction<{
docId: string;
linkState: LinkPopoverState;
}>
) => {
const { docId, linkState } = action.payload;
state[docId] = linkState;
},
updateLinkPopover: (
state,
action: PayloadAction<{
docId: string;
linkState: LinkPopoverState;
}>
) => {
const { docId, linkState } = action.payload;
const { id } = linkState;
if (!state[docId].open || state[docId].id !== id) return;
state[docId] = {
...state[docId],
...linkState,
};
},
closeLinkPopover: (state, action: PayloadAction<string>) => {
const docId = action.payload;
state[docId].open = false;
},
},
});
export const documentReducers = {
[documentSlice.name]: documentSlice.reducer,
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
[rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer,
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
[temporarySlice.name]: temporarySlice.reducer,
[blockEditSlice.name]: blockEditSlice.reducer,
};
@ -436,4 +392,3 @@ export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeActions = rangeSlice.actions;
export const slashCommandActions = slashCommandSlice.actions;
export const linkPopoverActions = linkPopoverSlice.actions;

View File

@ -2,6 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { DragInsertType } from '$app_reducers/block-draggable/slice';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { PageIcon } from '$app_reducers/pages/slice';
export const movePageThunk = createAsyncThunk(
'pages/movePage',
@ -56,3 +57,36 @@ export const movePageThunk = createAsyncThunk(
await controller.movePage({ parentId, prevId });
}
);
export const updatePageName = createAsyncThunk(
'pages/updateName',
async (
payload: {
id: string;
name: string;
},
thunkAPI
) => {
const controller = new PageController(payload.id);
await controller.updatePage({
id: payload.id,
name: payload.name,
});
}
);
export const updatePageIcon = createAsyncThunk(
'pages/updateIcon',
async (
payload: {
id: string;
icon?: PageIcon;
},
thunkAPI
) => {
const controller = new PageController(payload.id);
await controller.updatePageIcon(payload.icon);
}
);

View File

@ -1,4 +1,4 @@
import { ViewLayoutPB, ViewPB } from '@/services/backend';
import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Page {
@ -6,18 +6,28 @@ export interface Page {
parentId: string;
name: string;
layout: ViewLayoutPB;
icon?: string;
cover?: string;
icon?: PageIcon;
}
export function parserViewPBToPage(view: ViewPB) {
export interface PageIcon {
ty: ViewIconTypePB;
value: string;
}
export function parserViewPBToPage(view: ViewPB): Page {
const icon = view.icon;
return {
id: view.id,
name: view.name,
parentId: view.parent_view_id,
layout: view.layout,
cover: view.cover_url,
icon: view.icon_url,
icon: icon
? {
ty: icon.ty,
value: icon.value,
}
: undefined,
};
}
@ -30,7 +40,10 @@ export interface PageState {
export const initialState: PageState = {
pageMap: {},
relationMap: {},
expandedIdMap: {},
expandedIdMap: getExpandedPageIds().reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<string, boolean>),
};
export const pagesSlice = createSlice({
@ -75,16 +88,29 @@ export const pagesSlice = createSlice({
expandPage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedIdMap[id] = true;
const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
storeExpandedPageIds(ids);
},
collapsePage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedIdMap[id] = false;
const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
storeExpandedPageIds(ids);
},
},
});
export const pagesActions = pagesSlice.actions;
function storeExpandedPageIds(expandedPageIds: string[]) {
localStorage.setItem('expandedPageIds', JSON.stringify(expandedPageIds));
}
function getExpandedPageIds(): string[] {
const expandedPageIds = localStorage.getItem('expandedPageIds');
return expandedPageIds ? JSON.parse(expandedPageIds) : [];
}

View File

@ -1,9 +1,9 @@
import emojiData, { EmojiMartData } from '@emoji-mart/data';
export const randomEmoji = () => {
export const randomEmoji = (skin = 0) => {
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;
return emojis[randomKey].skins[skin].native;
};

View File

@ -6,7 +6,6 @@ import { useAppDispatch } from '../stores/store';
import { Log } from '../utils/log';
import {
documentActions,
linkPopoverActions,
rangeActions,
rectSelectionActions,
slashCommandActions,
@ -34,7 +33,6 @@ export const useDocument = () => {
dispatch(rangeActions.initialState(docId));
dispatch(rectSelectionActions.initialState(docId));
dispatch(slashCommandActions.initialState(docId));
dispatch(linkPopoverActions.initialState(docId));
},
[dispatch]
);
@ -46,7 +44,6 @@ export const useDocument = () => {
dispatch(rangeActions.clear(docId));
dispatch(rectSelectionActions.clear(docId));
dispatch(slashCommandActions.clear(docId));
dispatch(linkPopoverActions.clear(docId));
},
[dispatch]
);

View File

@ -15,6 +15,10 @@
background-color: var(--fill-list-active);
}
.MuiList-root .Mui-focusVisible.MuiMenuItem-root:not(:hover, .Mui-selected) {
background-color: unset;
}
.MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
background-image: none;
}

View File

@ -563,6 +563,9 @@
},
"inlineLink": {
"placeholder": "Paste or type a link",
"openInNewTab": "Open in new tab",
"copyLink": "Copy link",
"removeLink": "Remove link",
"url": {
"label": "Link URL",
"placeholder": "Enter link URL"
@ -651,7 +654,8 @@
"objects": "Objects",
"symbols": "Symbols",
"flags": "Flags",
"nature": "Nature"
"nature": "Nature",
"frequentlyUsed": "Frequently Used"
}
}
}

View File

@ -96,7 +96,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"collab",
@ -189,9 +189,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "aws-config"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc00553f5f3c06ffd4510a9d576f92143618706c45ea6ff81e84ad9be9588abd"
checksum = "bcdcf0d683fe9c23d32cf5b53c9918ea0a500375a9fb20109802552658e576c9"
dependencies = [
"aws-credential-types",
"aws-http",
@ -219,9 +219,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cb57ac6088805821f78d282c0ba8aec809f11cbee10dda19a97b03ab040ccc2"
checksum = "1fcdb2f7acbc076ff5ad05e7864bdb191ca70a6fd07668dc3a1a8bcd051de5ae"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@ -233,9 +233,9 @@ dependencies = [
[[package]]
name = "aws-endpoint"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c5f6f84a4f46f95a9bb71d9300b73cd67eb868bc43ae84f66ad34752299f4ac"
checksum = "8cce1c41a6cfaa726adee9ebb9a56fcd2bbfd8be49fd8a04c5e20fd968330b04"
dependencies = [
"aws-smithy-http",
"aws-smithy-types",
@ -247,9 +247,9 @@ dependencies = [
[[package]]
name = "aws-http"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a754683c322f7dc5167484266489fdebdcd04d26e53c162cad1f3f949f2c5671"
checksum = "aadbc44e7a8f3e71c8b374e03ecd972869eb91dd2bc89ed018954a52ba84bc44"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
@ -292,9 +292,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "0.27.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "babfd626348836a31785775e3c08a4c345a5ab4c6e06dfd9167f2bee0e6295d6"
checksum = "c8b812340d86d4a766b2ca73f740dfd47a97c2dff0c06c8517a16d88241957e4"
dependencies = [
"aws-credential-types",
"aws-endpoint",
@ -317,9 +317,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "0.27.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d0fbe3c2c342bc8dfea4bb43937405a8ec06f99140a0dcb9c7b59e54dfa93a1"
checksum = "265fac131fbfc188e5c3d96652ea90ecc676a934e3174eaaee523c6cec040b3b"
dependencies = [
"aws-credential-types",
"aws-endpoint",
@ -343,9 +343,9 @@ dependencies = [
[[package]]
name = "aws-sig-auth"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84dc92a63ede3c2cbe43529cb87ffa58763520c96c6a46ca1ced80417afba845"
checksum = "3b94acb10af0c879ecd5c7bdf51cda6679a0a4f4643ce630905a77673bfa3c61"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@ -357,9 +357,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "392fefab9d6fcbd76d518eb3b1c040b84728ab50f58df0c3c53ada4bea9d327e"
checksum = "9d2ce6f507be68e968a33485ced670111d1cbad161ddbbab1e313c03d37d8f4c"
dependencies = [
"aws-smithy-http",
"form_urlencoded",
@ -376,9 +376,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae23b9fe7a07d0919000116c4c5c0578303fbce6fc8d32efca1f7759d4c20faf"
checksum = "13bda3996044c202d75b91afeb11a9afae9db9a721c6a7a427410018e286b880"
dependencies = [
"futures-util",
"pin-project-lite",
@ -388,9 +388,9 @@ dependencies = [
[[package]]
name = "aws-smithy-client"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5230d25d244a51339273b8870f0f77874cd4449fb4f8f629b21188ae10cfc0ba"
checksum = "0a86aa6e21e86c4252ad6a0e3e74da9617295d8d6e374d552be7d3059c41cedd"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@ -412,9 +412,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b60e2133beb9fe6ffe0b70deca57aaeff0a35ad24a9c6fab2fd3b4f45b99fdb5"
checksum = "2b3b693869133551f135e1f2c77cb0b8277d9e3e17feaf2213f735857c4f0d28"
dependencies = [
"aws-smithy-types",
"bytes",
@ -434,9 +434,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-tower"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a4d94f556c86a0dd916a5d7c39747157ea8cb909ca469703e20fee33e448b67"
checksum = "3ae4f6c5798a247fac98a867698197d9ac22643596dc3777f0c76b91917616b9"
dependencies = [
"aws-smithy-http",
"aws-smithy-types",
@ -450,18 +450,18 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce3d6e6ebb00b2cce379f079ad5ec508f9bcc3a9510d9b9c1840ed1d6f8af39"
checksum = "23f9f42fbfa96d095194a632fbac19f60077748eba536eb0b9fecc28659807f8"
dependencies = [
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-query"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d58edfca32ef9bfbc1ca394599e17ea329cb52d6a07359827be74235b64b3298"
checksum = "98819eb0b04020a1c791903533b638534ae6c12e2aceda3e6e6fba015608d51d"
dependencies = [
"aws-smithy-types",
"urlencoding",
@ -469,9 +469,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58db46fc1f4f26be01ebdb821751b4e2482cd43aa2b64a0348fb89762defaffa"
checksum = "16a3d0bf4f324f4ef9793b86a1701d9700fbcdbd12a846da45eed104c634c6e8"
dependencies = [
"base64-simd",
"itoa",
@ -482,18 +482,18 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb557fe4995bd9ec87fb244bbb254666a971dc902a783e9da8b7711610e9664c"
checksum = "b1b9d12875731bd07e767be7baad95700c3137b56730ec9ddeedb52a5e5ca63b"
dependencies = [
"xmlparser",
]
[[package]]
name = "aws-types"
version = "0.55.2"
version = "0.55.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0869598bfe46ec44ffe17e063ed33336e59df90356ca8ff0e8da6f7c1d994b"
checksum = "6dd209616cc8d7bfb82f87811a5c655dc97537f592689b18743bddf5dc5c4829"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@ -925,7 +925,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"bytes",
@ -943,7 +943,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bytes",
"collab-sync",
@ -961,7 +961,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"async-trait",
@ -988,7 +988,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"proc-macro2",
"quote",
@ -1000,7 +1000,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"collab",
@ -1019,7 +1019,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"chrono",
@ -1039,7 +1039,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bincode",
"chrono",
@ -1059,7 +1059,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"anyhow",
"async-trait",
@ -1089,7 +1089,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
dependencies = [
"bytes",
"collab",
@ -3450,9 +3450,9 @@ dependencies = [
[[package]]
name = "postgrest"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e66400cb23a379592bc8c8bdc9adda652eef4a969b74ab78454a8e8c11330c2b"
checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7"
dependencies = [
"reqwest",
]
@ -4108,9 +4108,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe",
"rustls-pemfile",
@ -4229,15 +4229,15 @@ dependencies = [
[[package]]
name = "semver"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed"
checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
[[package]]
name = "serde"
version = "1.0.178"
version = "1.0.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60363bdd39a7be0266a520dab25fdc9241d2f987b08a01e01f0ec6d06a981348"
checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b"
dependencies = [
"serde_derive",
]
@ -4255,9 +4255,9 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.178"
version = "1.0.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28482318d6641454cb273da158647922d1be6b5a2fcc6165cd89ebdd7ed576b"
checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4"
dependencies = [
"proc-macro2",
"quote",
@ -5163,9 +5163,9 @@ dependencies = [
[[package]]
name = "urlencoding"
version = "2.1.2"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"

View File

@ -38,17 +38,17 @@ opt-level = 3
incremental = false
[patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
#collab = { path = "../AppFlowy-Collab/collab" }
#collab-folder = { path = "../AppFlowy-Collab/collab-folder" }
#collab-database= { path = "../AppFlowy-Collab/collab-database" }
#collab-document = { path = "../AppFlowy-Collab/collab-document" }
#collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" }
#appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" }
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
#
#collab = { path = "../../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../../AppFlowy-Collab/collab-folder" }
#collab-database= { path = "../../../AppFlowy-Collab/collab-database" }
#collab-document = { path = "../../../AppFlowy-Collab/collab-document" }
#collab-plugins = { path = "../../../AppFlowy-Collab/collab-plugins" }
#appflowy-integrate = { path = "../../../AppFlowy-Collab/appflowy-integrate" }

View File

@ -0,0 +1,85 @@
use crate::entities::parser::view::ViewIdentify;
use collab_folder::core::{IconType, ViewIcon};
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_error::ErrorCode;
#[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)]
pub enum ViewIconTypePB {
#[default]
Emoji = 0,
Url = 1,
Icon = 2,
}
impl std::convert::From<ViewIconTypePB> for IconType {
fn from(rev: ViewIconTypePB) -> Self {
match rev {
ViewIconTypePB::Emoji => IconType::Emoji,
ViewIconTypePB::Url => IconType::Url,
ViewIconTypePB::Icon => IconType::Icon,
}
}
}
impl Into<ViewIconTypePB> for IconType {
fn into(self) -> ViewIconTypePB {
match self {
IconType::Emoji => ViewIconTypePB::Emoji,
IconType::Url => ViewIconTypePB::Url,
IconType::Icon => ViewIconTypePB::Icon,
}
}
}
#[derive(Default, ProtoBuf, Debug, Clone, PartialEq, Eq)]
pub struct ViewIconPB {
#[pb(index = 1)]
pub ty: ViewIconTypePB,
#[pb(index = 2)]
pub value: String,
}
impl std::convert::From<ViewIconPB> for ViewIcon {
fn from(rev: ViewIconPB) -> Self {
ViewIcon {
ty: rev.ty.into(),
value: rev.value,
}
}
}
impl Into<ViewIconPB> for ViewIcon {
fn into(self) -> ViewIconPB {
ViewIconPB {
ty: self.ty.into(),
value: self.value,
}
}
}
#[derive(Default, ProtoBuf)]
pub struct UpdateViewIconPayloadPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2, one_of)]
pub icon: Option<ViewIconPB>,
}
#[derive(Clone, Debug)]
pub struct UpdateViewIconParams {
pub view_id: String,
pub icon: Option<ViewIcon>,
}
impl TryInto<UpdateViewIconParams> for UpdateViewIconPayloadPB {
type Error = ErrorCode;
fn try_into(self) -> Result<UpdateViewIconParams, Self::Error> {
let view_id = ViewIdentify::parse(self.view_id)?.0;
let icon = self.icon.map(|icon| icon.into());
Ok(UpdateViewIconParams { view_id, icon })
}
}

View File

@ -1,9 +1,11 @@
pub mod icon;
mod import;
mod parser;
pub mod trash;
pub mod view;
pub mod workspace;
pub use icon::*;
pub use import::*;
pub use trash::*;
pub use view::*;

View File

@ -6,10 +6,6 @@ pub struct ViewName(pub String);
impl ViewName {
pub fn parse(s: String) -> Result<ViewName, ErrorCode> {
if s.trim().is_empty() {
return Err(ErrorCode::ViewNameInvalid);
}
if s.graphemes(true).count() > 256 {
return Err(ErrorCode::ViewNameTooLong);
}

View File

@ -5,6 +5,7 @@ use std::sync::Arc;
use collab_folder::core::{View, ViewLayout};
use crate::entities::icon::ViewIconPB;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_error::ErrorCode;
@ -50,16 +51,11 @@ pub struct ViewPB {
#[pb(index = 6)]
pub layout: ViewLayoutPB,
/// The icon url of the view.
/// It can be used to save the emoji icon of the view.
/// The icon of the view.
#[pb(index = 7, one_of)]
pub icon_url: Option<String>,
pub icon: Option<ViewIconPB>,
/// The cover url of the view.
#[pb(index = 8, one_of)]
pub cover_url: Option<String>,
#[pb(index = 9)]
#[pb(index = 8)]
pub is_favorite: bool,
}
@ -71,9 +67,8 @@ pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
create_time: view.created_at,
child_views: Default::default(),
layout: view.layout.clone().into(),
icon_url: view.icon_url.clone(),
cover_url: view.cover_url.clone(),
is_favorite: view.is_favorite.clone(),
icon: view.icon.clone().map(|icon| icon.into()),
is_favorite: view.is_favorite,
}
}
@ -89,9 +84,8 @@ pub fn view_pb_with_child_views(view: Arc<View>, child_views: Vec<Arc<View>>) ->
.map(view_pb_without_child_views)
.collect(),
layout: view.layout.clone().into(),
icon_url: view.icon_url.clone(),
cover_url: view.cover_url.clone(),
is_favorite: view.is_favorite.clone(),
icon: view.icon.clone().map(|icon| icon.into()),
is_favorite: view.is_favorite,
}
}
@ -318,12 +312,6 @@ pub struct UpdateViewPayloadPB {
pub layout: Option<ViewLayoutPB>,
#[pb(index = 6, one_of)]
pub icon_url: Option<String>,
#[pb(index = 7, one_of)]
pub cover_url: Option<String>,
#[pb(index = 8, one_of)]
pub is_favorite: Option<bool>,
}
@ -333,10 +321,8 @@ pub struct UpdateViewParams {
pub name: Option<String>,
pub desc: Option<String>,
pub thumbnail: Option<String>,
pub icon_url: Option<String>,
pub cover_url: Option<String>,
pub is_favorite: Option<bool>,
pub layout: Option<ViewLayout>,
pub is_favorite: Option<bool>,
}
impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
@ -360,8 +346,6 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
Some(thumbnail) => Some(ViewThumbnail::parse(thumbnail)?.0),
};
let cover_url = self.cover_url;
let icon_url = self.icon_url;
let is_favorite = self.is_favorite;
Ok(UpdateViewParams {
@ -369,8 +353,6 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
name,
desc,
thumbnail,
cover_url,
icon_url,
is_favorite,
layout: self.layout.map(|ty| ty.into()),
})

View File

@ -139,6 +139,17 @@ pub(crate) async fn update_view_handler(
Ok(())
}
#[tracing::instrument(level = "debug", skip(data, folder), err)]
pub(crate) async fn update_view_icon_handler(
data: AFPluginData<UpdateViewIconPayloadPB>,
folder: AFPluginState<Weak<FolderManager>>,
) -> Result<(), FlowyError> {
let folder = upgrade_folder(folder)?;
let params: UpdateViewIconParams = data.into_inner().try_into()?;
folder.update_view_icon_with_params(params).await?;
Ok(())
}
pub(crate) async fn delete_view_handler(
data: AFPluginData<RepeatedViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,
@ -233,7 +244,7 @@ pub(crate) async fn read_favorites_handler(
views.push(view);
},
Err(err) => {
return Err(err.into());
return Err(err);
},
}
}

View File

@ -38,6 +38,7 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin {
.event(FolderEvent::DeleteAllTrash, delete_all_trash_handler)
.event(FolderEvent::ImportData, import_data_handler)
.event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler)
.event(FolderEvent::UpdateViewIcon, update_view_icon_handler)
.event(FolderEvent::ReadFavorites, read_favorites_handler)
.event(FolderEvent::ToggleFavorite, toggle_favorites_handler)
}
@ -149,4 +150,7 @@ pub enum FolderEvent {
#[event(input = "RepeatedViewIdPB")]
ToggleFavorite = 34,
#[event(input = "UpdateViewIconPayloadPB")]
UpdateViewIcon = 35,
}

View File

@ -8,7 +8,7 @@ use collab::core::collab::{CollabRawData, MutexCollab};
use collab::core::collab_state::SyncState;
use collab_folder::core::{
FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace,
View, ViewChange, ViewChangeReceiver, ViewLayout, ViewUpdate, Workspace,
};
use parking_lot::Mutex;
use tokio_stream::wrappers::WatchStream;
@ -18,6 +18,7 @@ use tracing::{event, Level};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_folder_deps::cloud::FolderCloudService;
use crate::entities::icon::UpdateViewIconParams;
use crate::entities::{
view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, CreateViewParams,
CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, FolderSnapshotStatePB, FolderSyncStatePB,
@ -448,7 +449,7 @@ impl FolderManager {
pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
self.with_folder((), |folder| {
if let Some(view) = folder.views.get_view(view_id) {
self.unfavorite_view_and_decendants(view.clone(), &folder);
self.unfavorite_view_and_decendants(view.clone(), folder);
folder.add_trash(vec![view_id.to_string()]);
// notify the parent view that the view is moved to trash
send_notification(view_id, FolderNotification::DidMoveViewToTrash)
@ -587,34 +588,29 @@ impl FolderManager {
/// Update the view with the given params.
#[tracing::instrument(level = "trace", skip(self), err)]
pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> {
let value = self.with_folder(None, |folder| {
let old_view = folder.views.get_view(&params.view_id);
let new_view = folder.views.update_view(&params.view_id, |update| {
self
.update_view(&params.view_id, |update| {
update
.set_name_if_not_none(params.name)
.set_desc_if_not_none(params.desc)
.set_layout_if_not_none(params.layout)
.set_icon_url_if_not_none(params.icon_url)
.set_cover_url_if_not_none(params.cover_url)
.set_favorite_if_not_none(params.is_favorite)
.done()
});
})
.await
}
Some((old_view, new_view))
});
if let Some((Some(old_view), Some(new_view))) = value {
if let Ok(handler) = self.get_handler(&old_view.layout) {
handler.did_update_view(&old_view, &new_view).await?;
}
}
if let Ok(view_pb) = self.get_view(&params.view_id).await {
send_notification(&view_pb.id, FolderNotification::DidUpdateView)
.payload(view_pb)
.send();
}
Ok(())
/// Update the icon of the view with the given params.
#[tracing::instrument(level = "trace", skip(self), err)]
pub async fn update_view_icon_with_params(
&self,
params: UpdateViewIconParams,
) -> FlowyResult<()> {
self
.update_view(&params.view_id, |update| {
update.set_icon(params.icon).done()
})
.await
}
/// Duplicate the view with the given view id.
@ -815,6 +811,32 @@ impl FolderManager {
Ok(view)
}
/// Update the view with the provided view_id using the specified function.
async fn update_view<F>(&self, view_id: &str, f: F) -> FlowyResult<()>
where
F: FnOnce(ViewUpdate) -> Option<View>,
{
let value = self.with_folder(None, |folder| {
let old_view = folder.views.get_view(view_id);
let new_view = folder.views.update_view(view_id, f);
Some((old_view, new_view))
});
if let Some((Some(old_view), Some(new_view))) = value {
if let Ok(handler) = self.get_handler(&old_view.layout) {
handler.did_update_view(&old_view, &new_view).await?;
}
}
if let Ok(view_pb) = self.get_view(view_id).await {
send_notification(&view_pb.id, FolderNotification::DidUpdateView)
.payload(view_pb)
.send();
}
Ok(())
}
/// Returns a handler that implements the [FolderOperationHandler] trait
fn get_handler(
&self,

View File

@ -4,7 +4,7 @@ use std::sync::Arc;
use bytes::Bytes;
pub use collab_folder::core::View;
use collab_folder::core::{RepeatedViewIdentifier, ViewIdentifier, ViewLayout};
use collab_folder::core::{RepeatedViewIdentifier, ViewIcon, ViewIdentifier, ViewLayout};
use tokio::sync::RwLock;
use flowy_error::FlowyError;
@ -55,8 +55,7 @@ pub struct ViewBuilder {
layout: ViewLayout,
child_views: Vec<ParentChildViews>,
is_favorite: bool,
icon_url: Option<String>,
cover_url: Option<String>,
icon: Option<ViewIcon>,
}
impl ViewBuilder {
@ -69,8 +68,7 @@ impl ViewBuilder {
layout: ViewLayout::Document,
child_views: vec![],
is_favorite: false,
icon_url: None,
cover_url: None,
icon: None,
}
}
@ -114,8 +112,7 @@ impl ViewBuilder {
created_at: timestamp(),
is_favorite: self.is_favorite,
layout: self.layout,
icon_url: self.icon_url,
cover_url: self.cover_url,
icon: self.icon,
children: RepeatedViewIdentifier::new(
self
.child_views
@ -257,8 +254,7 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View
created_at: time,
is_favorite: false,
layout,
cover_url: None,
icon_url: None,
icon: None,
}
}

View File

@ -1,5 +1,6 @@
use crate::script::{FolderScript::*, FolderTest};
use collab_folder::core::ViewLayout;
use flowy_folder2::entities::icon::{ViewIconPB, ViewIconTypePB};
#[tokio::test]
async fn read_all_workspace_test() {
@ -152,6 +153,32 @@ async fn view_update() {
assert_eq!(test.child_view.name, new_name);
}
#[tokio::test]
async fn view_icon_update_test() {
let mut test = FolderTest::new().await;
let view = test.child_view.clone();
let new_icon = ViewIconPB {
ty: ViewIconTypePB::Emoji,
value: "👍".to_owned(),
};
assert!(view.icon.is_none());
test
.run_scripts(vec![
UpdateViewIcon {
icon: Some(new_icon.clone()),
},
ReadView(view.id.clone()),
])
.await;
assert_eq!(test.child_view.icon, Some(new_icon));
test
.run_scripts(vec![UpdateViewIcon { icon: None }, ReadView(view.id)])
.await;
assert_eq!(test.child_view.icon, None);
}
#[tokio::test]
#[should_panic]
async fn view_delete() {
@ -263,8 +290,8 @@ async fn toggle_favorites() {
ReadView(view.id.clone()),
])
.await;
assert_eq!(test.child_view.is_favorite, true);
assert!(test.favorites.len() != 0);
assert!(test.child_view.is_favorite);
assert_ne!(test.favorites.len(), 0);
assert_eq!(test.favorites[0].id, view.id);
let view = test.child_view.clone();
@ -293,12 +320,12 @@ async fn delete_favorites() {
ReadView(view.id.clone()),
])
.await;
assert_eq!(test.child_view.is_favorite, true);
assert!(test.favorites.len() != 0);
assert!(test.child_view.is_favorite);
assert_ne!(test.favorites.len(), 0);
assert_eq!(test.favorites[0].id, view.id);
test.run_scripts(vec![DeleteView, ReadFavorites]).await;
assert!(test.favorites.len() == 0);
assert_eq!(test.favorites.len(), 0);
}
#[tokio::test]

View File

@ -1,5 +1,6 @@
use collab_folder::core::ViewLayout;
use flowy_folder2::entities::icon::{UpdateViewIconPayloadPB, ViewIconPB};
use flowy_folder2::entities::*;
use flowy_folder2::event_map::FolderEvent::*;
use flowy_test::event_builder::EventBuilder;
@ -42,6 +43,9 @@ pub enum FolderScript {
desc: Option<String>,
is_favorite: Option<bool>,
},
UpdateViewIcon {
icon: Option<ViewIconPB>,
},
DeleteView,
DeleteViews(Vec<String>),
MoveView {
@ -164,6 +168,9 @@ impl FolderTest {
} => {
update_view(sdk, &self.child_view.id, name, desc, is_favorite).await;
},
FolderScript::UpdateViewIcon { icon } => {
update_view_icon(sdk, &self.child_view.id, icon).await;
},
FolderScript::DeleteView => {
delete_view(sdk, vec![self.child_view.id.clone()]).await;
},
@ -333,6 +340,18 @@ pub async fn update_view(
.await;
}
pub async fn update_view_icon(sdk: &FlowyCoreTest, view_id: &str, icon: Option<ViewIconPB>) {
let request = UpdateViewIconPayloadPB {
view_id: view_id.to_string(),
icon,
};
EventBuilder::new(sdk.clone())
.event(UpdateViewIcon)
.payload(request)
.async_send()
.await;
}
pub async fn delete_view(sdk: &FlowyCoreTest, view_ids: Vec<String>) {
let request = RepeatedViewIdPB { items: view_ids };
EventBuilder::new(sdk.clone())

View File

@ -16,6 +16,7 @@ use flowy_database2::entities::*;
use flowy_database2::event_map::DatabaseEvent;
use flowy_document2::entities::{DocumentDataPB, OpenDocumentPayloadPB};
use flowy_document2::event_map::DocumentEvent;
use flowy_folder2::entities::icon::UpdateViewIconPayloadPB;
use flowy_folder2::entities::*;
use flowy_folder2::event_map::FolderEvent;
use flowy_notification::entities::SubscribeObject;
@ -184,6 +185,15 @@ impl FlowyCoreTest {
.error()
}
pub async fn update_view_icon(&self, payload: UpdateViewIconPayloadPB) -> Option<FlowyError> {
EventBuilder::new(self.clone())
.event(FolderEvent::UpdateViewIcon)
.payload(payload)
.async_send()
.await
.error()
}
pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB {
let payload = CreateViewPayloadPB {
parent_view_id: parent_id.to_string(),
@ -797,7 +807,7 @@ impl Cleaner {
Cleaner(dir)
}
fn cleanup(dir: &PathBuf) {
fn cleanup(_dir: &PathBuf) {
// let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -1,3 +1,4 @@
use flowy_folder2::entities::icon::{UpdateViewIconPayloadPB, ViewIconPB, ViewIconTypePB};
use flowy_folder2::entities::*;
use flowy_test::event_builder::EventBuilder;
use flowy_test::FlowyCoreTest;
@ -83,45 +84,27 @@ async fn update_view_event_with_name_test() {
}
#[tokio::test]
async fn update_view_event_with_icon_url_test() {
async fn update_view_icon_event_test() {
let test = FlowyCoreTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await.workspace;
let view = test
.create_view(&current_workspace.id, "My first view".to_string())
.await;
let new_icon = ViewIconPB {
ty: ViewIconTypePB::Emoji,
value: "👍".to_owned(),
};
let error = test
.update_view(UpdateViewPayloadPB {
.update_view_icon(UpdateViewIconPayloadPB {
view_id: view.id.clone(),
icon_url: Some("appflowy.io".to_string()),
..Default::default()
icon: Some(new_icon.clone()),
})
.await;
assert!(error.is_none());
let view = test.get_view(&view.id).await;
assert_eq!(view.icon_url.unwrap(), "appflowy.io");
}
#[tokio::test]
async fn update_view_event_with_cover_url_test() {
let test = FlowyCoreTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await.workspace;
let view = test
.create_view(&current_workspace.id, "My first view".to_string())
.await;
let error = test
.update_view(UpdateViewPayloadPB {
view_id: view.id.clone(),
cover_url: Some("appflowy.io".to_string()),
..Default::default()
})
.await;
assert!(error.is_none());
let view = test.get_view(&view.id).await;
assert_eq!(view.cover_url.unwrap(), "appflowy.io");
assert_eq!(view.icon, Some(new_icon));
}
#[tokio::test]

View File

@ -42,19 +42,18 @@ impl UserLocalDataMigration {
pub fn run(self, migrations: Vec<Box<dyn UserDataMigration>>) -> FlowyResult<Vec<String>> {
let mut applied_migrations = vec![];
let conn = self.sqlite_pool.get()?;
let record = get_all_records(&*conn)?;
let record = get_all_records(&conn)?;
let mut duplicated_names = vec![];
for migration in migrations {
if record
if !record
.iter()
.find(|record| record.migration_name == migration.name())
.is_none()
.any(|record| record.migration_name == migration.name())
{
let migration_name = migration.name().to_string();
if !duplicated_names.contains(&migration_name) {
migration.run(&self.session, &self.collab_db)?;
applied_migrations.push(migration.name().to_string());
save_record(&*conn, &migration_name);
save_record(&conn, &migration_name);
duplicated_names.push(migration_name);
} else {
tracing::error!("Duplicated migration name: {}", migration_name);

View File

@ -86,7 +86,7 @@ impl UserSession {
.run(vec![Box::new(HistoricalEmptyDocumentMigration)])
{
Ok(applied_migrations) => {
if applied_migrations.len() > 0 {
if !applied_migrations.is_empty() {
tracing::info!("Did apply migrations: {:?}", applied_migrations);
}
},