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_listener.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.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/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/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_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), (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) = const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) =
_DidReceiveRowMeta; _DidReceiveRowMeta;
const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) = const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) =
_DidReceiveFieldUdate; _DidReceiveFieldUpdate;
const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon; const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon;
const factory RowBannerEvent.setCover(String coverURL) = _SetCover; const factory RowBannerEvent.setCover(String coverURL) = _SetCover;
} }

View File

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

View File

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

View File

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

View File

@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]] [[package]]
name = "appflowy-integrate" name = "appflowy-integrate"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -1021,7 +1021,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -1039,7 +1039,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-client-ws" name = "collab-client-ws"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab-sync", "collab-sync",
@ -1057,7 +1057,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1084,7 +1084,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-derive" name = "collab-derive"
version = "0.1.0" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1096,7 +1096,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -1115,7 +1115,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -1135,7 +1135,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-persistence" name = "collab-persistence"
version = "0.1.0" 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 = [ dependencies = [
"bincode", "bincode",
"chrono", "chrono",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1185,7 +1185,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-sync" name = "collab-sync"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab", "collab",

View File

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

View File

@ -1,8 +1,8 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store'; 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 { UserSettingController } from '$app/stores/effects/user/user_setting_controller';
import { currentUserActions } from '$app_reducers/current-user/slice'; 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 { createTheme } from '@mui/material/styles';
import { getDesignTokens } from '$app/utils/mui'; import { getDesignTokens } from '$app/utils/mui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -18,28 +18,18 @@ export function useUserSetting() {
return controller; return controller;
}, [currentUser?.id]); }, [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(() => { useEffect(() => {
userSettingController?.getAppearanceSetting().then((res) => { void loadUserSetting();
if (!res) return; }, [loadUserSetting]);
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]);
const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => { const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
return state.currentUser.userSetting || {}; return state.currentUser.userSetting || {};

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 emojiData, { EmojiMartData } from '@emoji-mart/data';
import { init, FrequentlyUsed, getEmojiDataFromNative, Store } from 'emoji-mart';
import { PopoverProps } from '@mui/material/Popover'; import { PopoverProps } from '@mui/material/Popover';
import { PopoverOrigin } from '@mui/material/Popover/Popover'; import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { chunkArray } from '$app/utils/tool'; 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 { export interface EmojiCategory {
id: string; id: string;
emojis: Emoji[]; emojis: Emoji[];
@ -15,14 +23,24 @@ interface Emoji {
name: string; name: string;
native: string; native: string;
} }
export function useLoadEmojiData({ skin }: { skin: number }) {
export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]); const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
const [skin, setSkin] = useState<number>(() => {
return Number(Store.get('skin')) || 0;
});
useEffect(() => { 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 { emojis, categories } = emojiData as EmojiMartData;
const emojiCategories = categories const filteredCategories = categories
.map((category) => { .map((category) => {
const { id, emojis: categoryEmojis } = category; const { id, emojis: categoryEmojis } = category;
@ -32,15 +50,15 @@ export function useLoadEmojiData({ skin }: { skin: number }) {
.filter((emojiId) => { .filter((emojiId) => {
const emoji = emojis[emojiId]; const emoji = emojis[emojiId];
if (!searchValue) return true; if (!searchVal) return true;
return filterSearchValue(emoji, searchValue); return filterSearchValue(emoji, searchVal);
}) })
.map((emojiId) => { .map((emojiId) => {
const emoji = emojis[emojiId]; const emoji = emojis[emojiId];
const { id, name, skins } = emoji; const { name, skins } = emoji;
return { return {
id, id: emojiId,
name, name,
native: skins[skin] ? skins[skin].native : skins[0].native, native: skins[skin] ? skins[skin].native : skins[0].native,
}; };
@ -49,14 +67,43 @@ export function useLoadEmojiData({ skin }: { skin: number }) {
}) })
.filter((category) => category.emojis.length > 0); .filter((category) => category.emojis.length > 0);
setEmojiCategories(emojiCategories); setEmojiCategories(filteredCategories);
}, [skin, searchValue]); },
[skin]
);
useEffect(() => {
void (async () => {
await init({ data: emojiData, maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, perLine: PER_ROW_EMOJI_COUNT });
await loadEmojiData();
})();
}, [loadEmojiData]);
useEffect(() => {
void loadEmojiData(searchValue);
}, [loadEmojiData, searchValue]);
const onSelect = useCallback(
async (native: string) => {
onEmojiSelect(native);
if (!native) {
return;
}
const data = await getEmojiDataFromNative(native);
FrequentlyUsed.add(data);
},
[onEmojiSelect]
);
return { return {
emojiCategories, emojiCategories,
skin,
setSearchValue, setSearchValue,
searchValue, searchValue,
onSelect,
onSkinChange,
skin,
}; };
} }
@ -124,9 +171,8 @@ export function useVirtualizedCategories({ count }: { count: number }) {
count, count,
getScrollElement: () => ref.current, getScrollElement: () => ref.current,
estimateSize: () => { estimateSize: () => {
return 60; return EMOJI_SIZE;
}, },
overscan: 3,
}); });
return { virtualize, ref }; return { virtualize, ref };

View File

@ -1,7 +1,9 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
EMOJI_SIZE,
EmojiCategory, EmojiCategory,
getRowsWithCategories, getRowsWithCategories,
PER_ROW_EMOJI_COUNT,
useVirtualizedCategories, useVirtualizedCategories,
} from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks'; } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -16,7 +18,7 @@ function EmojiPickerCategories({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const rows = useMemo(() => { const rows = useMemo(() => {
return getRowsWithCategories(emojiCategories, 13); return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
}, [emojiCategories]); }, [emojiCategories]);
const { ref, virtualize } = useVirtualizedCategories({ const { ref, virtualize } = useVirtualizedCategories({
@ -27,6 +29,7 @@ function EmojiPickerCategories({
const getCategoryName = useCallback( const getCategoryName = useCallback(
(id: string) => { (id: string) => {
const i18nName: Record<string, string> = { const i18nName: Record<string, string> = {
frequent: t('emoji.categories.frequentlyUsed'),
people: t('emoji.categories.people'), people: t('emoji.categories.people'),
nature: t('emoji.categories.nature'), nature: t('emoji.categories.nature'),
foods: t('emoji.categories.food'), foods: t('emoji.categories.food'),
@ -43,7 +46,12 @@ function EmojiPickerCategories({
); );
return ( 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 <div
style={{ style={{
height: virtualize.getTotalSize(), height: virtualize.getTotalSize(),
@ -72,7 +80,14 @@ function EmojiPickerCategories({
<div className={'flex'}> <div className={'flex'}>
{item.emojis?.map((emoji) => { {item.emojis?.map((emoji) => {
return ( 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 <IconButton
size={'small'} size={'small'}
onClick={() => { onClick={() => {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Box, IconButton } from '@mui/material'; 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 TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import { randomEmoji } from '$app/utils/document/emoji'; import { randomEmoji } from '$app/utils/document/emoji';
@ -12,26 +12,26 @@ import { useTranslation } from 'react-i18next';
const skinTones = [ const skinTones = [
{ {
value: 0, value: 0,
label: '✋', color: '#ffc93a',
}, },
{ {
label: '✋🏻', color: '#ffdab7',
value: 1, value: 1,
}, },
{ {
label: '✋🏼', color: '#e7b98f',
value: 2, value: 2,
}, },
{ {
label: '✋🏽', color: '#c88c61',
value: 3, value: 3,
}, },
{ {
label: '✋🏾', color: '#a46134',
value: 4, value: 4,
}, },
{ {
label: '✋🏿', color: '#5d4437',
value: 5, value: 5,
}, },
]; ];
@ -78,7 +78,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
<Tooltip title={t('emoji.selectSkinTone')}> <Tooltip title={t('emoji.selectSkinTone')}>
<div className={'random-emoji-btn mr-2 rounded border border-line-divider'}> <div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
<IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}> <IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
{skinTones[skin].label} <Circle
style={{
fill: skinTones[skin].color,
}}
/>
</IconButton> </IconButton>
</div> </div>
</Tooltip> </Tooltip>
@ -100,7 +104,7 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
<div className={'mx-0.5'} key={skinTone.value}> <div className={'mx-0.5'} key={skinTone.value}>
<IconButton <IconButton
style={{ style={{
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : 'transparent', backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
}} }}
size={'small'} size={'small'}
onClick={() => { onClick={() => {
@ -108,7 +112,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
popoverProps.onClose?.(); popoverProps.onClose?.();
}} }}
> >
{skinTone.label} <Circle
style={{
fill: skinTone.color,
}}
/>
</IconButton> </IconButton>
</div> </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 { useLoadEmojiData } from './EmojiPicker.hooks';
import EmojiPickerHeader from './EmojiPickerHeader';
import EmojiPickerHeader from '$app/components/_shared/EmojiPicker/EmojiPickerHeader'; import EmojiPickerCategories from './EmojiPickerCategories';
import EmojiPickerCategories from '$app/components/_shared/EmojiPicker/EmojiPickerCategories';
interface Props { interface Props {
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
} }
function EmojiPickerComponent({ onEmojiSelect }: Props) { function EmojiPicker(props: Props) {
const [skin, setSkin] = useState(0); const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
const { emojiCategories, setSearchValue, searchValue } = useLoadEmojiData({
skin,
});
return ( return (
<div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}> <div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
<EmojiPickerHeader <EmojiPickerHeader
onEmojiSelect={onEmojiSelect} onEmojiSelect={onSelect}
skin={skin} skin={skin}
onSkinSelect={setSkin} onSkinSelect={onSkinChange}
searchValue={searchValue} searchValue={searchValue}
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
/> />
<EmojiPickerCategories onEmojiSelect={onEmojiSelect} emojiCategories={emojiCategories} /> <EmojiPickerCategories onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
</div> </div>
); );
} }
export default EmojiPickerComponent; export default EmojiPicker;

View File

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

View File

@ -10,7 +10,7 @@ import { get } from '$app/utils/tool';
const headingBlockTopOffset: Record<number, string> = { const headingBlockTopOffset: Record<number, string> = {
1: '0.4rem', 1: '0.4rem',
2: '0.2rem', 2: '0.35rem',
3: '0.15rem', 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 React, { useCallback, useState } from 'react';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import EmojiPicker from '$app/components/_shared/EmojiPicker'; import EmojiPicker from '$app/components/_shared/EmojiPicker';
import { PageIcon } from '$app_reducers/pages/slice';
function DocumentIcon({ function DocumentIcon({
icon, icon,
className, className,
onUpdateIcon, onUpdateIcon,
}: { }: {
icon?: string; icon?: PageIcon;
className?: string; className?: string;
onUpdateIcon: (icon: string) => void; onUpdateIcon: (icon: string) => void;
}) { }) {
@ -41,13 +42,15 @@ function DocumentIcon({
<> <>
<div className={`absolute bottom-0 left-0 pt-[20px] ${className}`}> <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'}> <div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl hover:text-7xl'}>
{icon} {icon.value}
</div> </div>
</div> </div>
<Popover <Popover
open={open} open={open}
anchorReference='anchorPosition' anchorReference='anchorPosition'
anchorPosition={anchorPosition} anchorPosition={anchorPosition}
disableAutoFocus
disableRestoreFocus
onClose={() => setAnchorPosition(undefined)} onClose={() => setAnchorPosition(undefined)}
> >
<EmojiPicker onEmojiSelect={onEmojiSelect} /> <EmojiPicker onEmojiSelect={onEmojiSelect} />

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 Button from '@mui/material/Button';
import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image'; import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image';
import { Log } from '$app/utils/log'; 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 }) { function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) {
const { t } = useTranslation(); const { t } = useTranslation();

View File

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

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit'; 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 { interface Props {
onSelected: (image: Image) => void; 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 { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
import { useCallback } from 'react'; import { useEffect } from 'react';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppDispatch } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { documentActions } from '$app_reducers/document/slice';
export function useDocumentTitle(id: string) { export function useDocumentTitle(id: string) {
const { node } = useSubscribeNode(id); const { node } = useSubscribeNode(id);
const { controller } = useSubscribeDocument();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onUpdateIcon = useCallback( const { docId } = useSubscribeDocument();
(icon: string) => { const page = useAppSelector((state) => state.pages.pageMap[docId]);
dispatch(
updateNodeDataThunk({
id,
data: {
icon,
},
controller,
})
);
},
[controller, dispatch, id]
);
const onUpdateCover = useCallback( useEffect(() => {
(coverType: 'image' | 'color' | '', cover: string) => { if (page) {
dispatch( dispatch(
updateNodeDataThunk({ documentActions.updateRootNodeDelta({
id, docId,
data: { delta: [{ insert: page.name }],
cover, rootId: id,
coverType,
},
controller,
}) })
); );
}, }
[controller, dispatch, id] }, [dispatch, docId, id, page]);
);
return { return {
node, 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 { useDocumentTitle } from './DocumentTitle.hooks';
import TextBlock from '../TextBlock'; import TextBlock from '../TextBlock';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TitleButtonGroup from './TitleButtonGroup'; import DocumentBanner from '$app/components/document/DocumentBanner';
import DocumentTopPanel from './DocumentTopPanel';
export default function DocumentTitle({ id }: { id: string }) { export default function DocumentTitle({ id }: { id: string }) {
const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id); const { node } = useDocumentTitle(id);
const { t } = useTranslation(); const { t } = useTranslation();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
@ -14,14 +13,7 @@ export default function DocumentTitle({ id }: { id: string }) {
return ( return (
<div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}> <div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<DocumentTopPanel onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} node={node} /> <DocumentBanner id={node.id} hover={hover} />
<div
style={{
opacity: hover ? 1 : 0,
}}
>
<TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
</div>
<div data-block-id={node.id} className='doc-title relative text-4xl font-bold'> <div data-block-id={node.id} className='doc-title relative text-4xl font-bold'>
<TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} /> <TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
</div> </div>

View File

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

View File

@ -10,8 +10,8 @@ export default function QuoteBlock({
childIds?: string[]; childIds?: string[];
}) { }) {
return ( return (
<div className={'py-[2px]'}> <div className={'py-[2px] pl-0.5'}>
<div className={'border-l-4 border-solid border-fill-default px-3 '}> <div className={'border-l-4 border-solid border-fill-default pl-3'}>
<TextBlock node={node} /> <TextBlock node={node} />
<NodeChildren childIds={childIds} /> <NodeChildren childIds={childIds} />
</div> </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 { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; 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 { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME } from '$app/constants/document/name'; import { RANGE_NAME } from '$app/constants/document/name';
import { createTemporary } from '$app_reducers/document/async-actions/temporary'; import { createTemporary } from '$app_reducers/document/async-actions/temporary';
@ -57,14 +56,6 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
[controller, dispatch, isActive] [controller, dispatch, isActive]
); );
const addLink = useCallback(() => {
dispatch(
newLinkThunk({
docId,
})
);
}, [dispatch, docId]);
const addTemporaryInput = useCallback( const addTemporaryInput = useCallback(
(type: TemporaryType) => { (type: TemporaryType) => {
dispatch(createTemporary({ type, docId })); dispatch(createTemporary({ type, docId }));
@ -103,12 +94,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
case TextAction.Code: case TextAction.Code:
return toggleFormat(format); return toggleFormat(format);
case TextAction.Link: case TextAction.Link:
return addLink(); return addTemporaryInput(TemporaryType.Link);
case TextAction.Equation: case TextAction.Equation:
return addTemporaryInput(TemporaryType.Equation); return addTemporaryInput(TemporaryType.Equation);
} }
}, },
[addLink, addTemporaryInput, toggleFormat] [addTemporaryInput, toggleFormat]
); );
const formatIcon = useMemo(() => { const formatIcon = useMemo(() => {

View File

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

View File

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

View File

@ -25,7 +25,6 @@ export function useEditor({
decorateSelection, decorateSelection,
onKeyDown, onKeyDown,
isCodeBlock, isCodeBlock,
linkDecorateSelection,
temporarySelection, temporarySelection,
}: EditorProps) { }: EditorProps) {
const { editor } = useSlateYjs({ delta }); const { editor } = useSlateYjs({ delta });
@ -97,10 +96,6 @@ export function useEditor({
getDecorateRange(path, decorateSelection, { getDecorateRange(path, decorateSelection, {
selection_high_lighted: true, selection_high_lighted: true,
}), }),
getDecorateRange(path, linkDecorateSelection?.selection, {
link_selection_lighted: true,
link_placeholder: linkDecorateSelection?.placeholder,
}),
getDecorateRange(path, temporarySelection, { getDecorateRange(path, temporarySelection, {
temporary: true, temporary: true,
}), }),
@ -108,7 +103,7 @@ export function useEditor({
return ranges; return ranges;
}, },
[temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange] [temporarySelection, decorateSelection, getDecorateRange]
); );
const onKeyDownRewrite = useCallback( 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; 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 { return {
decorateSelection, decorateSelection,
linkDecorateSelection,
temporarySelection, 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 { Functions } from '@mui/icons-material';
import KatexMath from '$app/components/document/_shared/KatexMath'; 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 { useAppDispatch } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks'; import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
import LinkEditContent from '$app/components/document/_shared/TemporaryInput/LinkEditContent';
const AFTER_RENDER_DELAY = 100; const AFTER_RENDER_DELAY = 100;
@ -97,7 +98,7 @@ function TemporaryPopover() {
case TemporaryType.Equation: case TemporaryType.Equation:
return ( return (
<EquationEditContent <EquationEditContent
value={data.latex} value={data.latex || ''}
onChange={(latex: string) => onChange={(latex: string) =>
onChangeData({ onChangeData({
latex, latex,
@ -106,6 +107,17 @@ function TemporaryPopover() {
onConfirm={onConfirm} 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]); }, [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 { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation'; import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks'; 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 { useAppDispatch } from '$app/stores/store';
import { temporaryActions } from '$app_reducers/document/temporary_slice'; import { temporaryActions } from '$app_reducers/document/temporary_slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import TemporaryLink from '$app/components/document/_shared/TemporaryInput/TemporaryLink';
function TemporaryInput({ function TemporaryInput({
leaf, leaf,
@ -21,7 +22,9 @@ function TemporaryInput({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const { docId } = useSubscribeDocument(); const { docId } = useSubscribeDocument();
const match = useMemo(() => { const [match, setMatch] = useState(false);
const getMatch = useCallback(() => {
if (!ref.current) return false; if (!ref.current) return false;
if (!leaf.text) return false; if (!leaf.text) return false;
if (!temporaryState) return false; if (!temporaryState) return false;
@ -29,6 +32,7 @@ function TemporaryInput({
const selection = getSelection(ref.current); const selection = getSelection(ref.current);
if (!selection) return false; if (!selection) return false;
return leaf.text === selectedText || selection.index <= temporaryState.selection.index; return leaf.text === selectedText || selection.index <= temporaryState.selection.index;
}, [leaf.text, temporaryState, getSelection]); }, [leaf.text, temporaryState, getSelection]);
@ -38,7 +42,9 @@ function TemporaryInput({
switch (type) { switch (type) {
case TemporaryType.Equation: case TemporaryType.Equation:
return <TemporaryEquation latex={data.latex} />; return <TemporaryEquation latex={data.latex || ''} />;
case TemporaryType.Link:
return <TemporaryLink {...data} />;
default: default:
return null; return null;
} }
@ -69,6 +75,11 @@ function TemporaryInput({
}); });
}, [dispatch, docId, id, match, setAnchorPosition]); }, [dispatch, docId, id, match, setAnchorPosition]);
useEffect(() => {
const match = getMatch();
setMatch(match);
}, [getMatch]);
return ( return (
<span ref={ref}> <span ref={ref}>
{match ? renderPlaceholder() : null} {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 { useCallback, useEffect, useMemo, useState } from 'react';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
import { Page, pagesActions } from '$app_reducers/pages/slice'; import { Page } from '$app_reducers/pages/slice';
import { Log } from '$app/utils/log';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PageController } from "$app/stores/effects/workspace/page/page_controller";
export function useLoadExpandedPages() { export function useLoadExpandedPages() {
const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]); const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
const currentPageId = params.id; const currentPageId = params.id;
const pageMap = useAppSelector((state) => state.pages.pageMap);
const [pagePath, setPagePath] = useState< const [pagePath, setPagePath] = useState<
( (
| Page | Page
@ -22,37 +21,39 @@ export function useLoadExpandedPages() {
)[] )[]
>([]); >([]);
const loadPage = useCallback( const loadPagePath = useCallback(
async (id: string) => { async (pageId: string) => {
if (!id) return; let page = pageMap[pageId];
const controller = new PageController(id); const controller = new PageController(pageId);
if (!page) {
try { try {
const page = await controller.getPage(); 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) { } catch (e) {
Log.info(`${id} is workspace`); // do nothing
} }
},
[dispatch] if (!page) {
); return;
}
}
setPagePath(prev => {
return [
page,
...prev
]
});
await loadPagePath(page.parentId);
}, [pageMap]);
useEffect(() => { useEffect(() => {
setPagePath([]); setPagePath([]);
if (!currentPageId) { if (!currentPageId) {
return; return;
} }
loadPagePath(currentPageId);
void (async () => { // eslint-disable-next-line react-hooks/exhaustive-deps
await loadPage(currentPageId); }, [currentPageId]);
})();
}, [currentPageId, dispatch, loadPage]);
useEffect(() => { useEffect(() => {
if (isTrash) { if (isTrash) {

View File

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

View File

@ -6,6 +6,7 @@ import AddButton from './AddButton';
import MoreButton from './MoreButton'; import MoreButton from './MoreButton';
import { ViewLayoutPB } from '@/services/backend'; import { ViewLayoutPB } from '@/services/backend';
import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks'; import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks';
import { useTranslation } from 'react-i18next';
function NestedPageTitle({ function NestedPageTitle({
pageId, pageId,
@ -26,6 +27,7 @@ function NestedPageTitle({
onDuplicate: () => Promise<void>; onDuplicate: () => Promise<void>;
onRename: (newName: string) => Promise<void>; onRename: (newName: string) => Promise<void>;
}) { }) {
const { t } = useTranslation();
const page = useAppSelector((state) => { const page = useAppSelector((state) => {
return state.pages.pageMap[pageId]; return state.pages.pageMap[pageId];
}); });
@ -47,7 +49,7 @@ function NestedPageTitle({
toggleCollapsed(); toggleCollapsed();
}} }}
style={{ 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'} className={'flex h-[100%] w-8 items-center justify-center p-2'}
> >
@ -55,7 +57,11 @@ function NestedPageTitle({
<ArrowRightSvg /> <ArrowRightSvg />
</div> </div>
</button> </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>
<div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}> <div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}>
<AddButton isVisible={isHovering} onAddPage={onAddPage} /> <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 DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
@ -21,6 +21,10 @@ function RenameDialog({
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [error, setError] = useState(false); const [error, setError] = useState(false);
useEffect(() => {
setValue(defaultValue);
setError(false);
}, [defaultValue]);
return ( return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}> <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle> <DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
@ -37,6 +41,7 @@ function RenameDialog({
variant='standard' variant='standard'
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>{t('button.Cancel')}</Button> <Button onClick={onClose}>{t('button.Cancel')}</Button>
<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 Collapse from '@mui/material/Collapse';
import { TransitionGroup } from 'react-transition-group'; import { TransitionGroup } from 'react-transition-group';
import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle'; import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
@ -10,6 +10,10 @@ function NestedPage({ pageId }: { pageId: string }) {
const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId); const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId); const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
const children = useMemo(() => {
return collapsed ? [] : childPages;
}, [collapsed, childPages]);
return ( return (
<BlockDraggable id={pageId} type={BlockDraggableType.PAGE} data-page-id={pageId}> <BlockDraggable id={pageId} type={BlockDraggableType.PAGE} data-page-id={pageId}>
<NestedPageTitle <NestedPageTitle
@ -27,7 +31,7 @@ function NestedPage({ pageId }: { pageId: string }) {
<div className={'pl-4 pt-[2px]'}> <div className={'pl-4 pt-[2px]'}>
<TransitionGroup> <TransitionGroup>
{childPages?.map((pageId) => ( {children?.map((pageId) => (
<Collapse key={pageId}> <Collapse key={pageId}>
<NestedPage key={pageId} pageId={pageId} /> <NestedPage key={pageId} pageId={pageId} />
</Collapse> </Collapse>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc'; 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 { export class UserSettingController {
private readonly backendService: UserBackendService; private readonly backendService: UserBackendService;
@ -17,11 +18,25 @@ export class UserSettingController {
return {}; return {};
}; };
getAppearanceSetting = async (): Promise<AppearanceSettingsPB | undefined> => { getAppearanceSetting = async (): Promise<Partial<UserSetting> | undefined> => {
const appearanceSetting = await this.backendService.getAppearanceSettings(); const appearanceSetting = await this.backendService.getAppearanceSettings();
if (appearanceSetting.ok) { 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; return;

View File

@ -14,8 +14,11 @@ import {
ImportPB, ImportPB,
MoveNestedViewPayloadPB, MoveNestedViewPayloadPB,
FolderEventMoveNestedView, FolderEventMoveNestedView,
ViewIconPB,
UpdateViewIconPayloadPB,
FolderEventUpdateViewIcon,
} from '@/services/backend/events/flowy-folder2'; } from '@/services/backend/events/flowy-folder2';
import { Page } from '$app_reducers/pages/slice'; import { Page, PageIcon } from '$app_reducers/pages/slice';
export class PageBackendService { export class PageBackendService {
constructor() { constructor() {
@ -54,17 +57,23 @@ export class PageBackendService {
payload.name = page.name; 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); 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) => { deletePage = async (viewId: string) => {
const payload = new RepeatedViewIdPB({ const payload = new RepeatedViewIdPB({
items: [viewId], 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 { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer'; import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; import { Page, PageIcon, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
export class PageController { export class PageController {
private readonly backendService: PageBackendService = new PageBackendService(); private readonly backendService: PageBackendService = new PageBackendService();
private readonly observer: WorkspaceObserver = new WorkspaceObserver(); private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private onChangeQueue?: AsyncQueue;
constructor(private readonly id: string) { constructor(private readonly id: string) {
// //
} }
@ -72,22 +70,15 @@ export class PageController {
return this.getPage(parentPageId); return this.getPage(parentPageId);
}; };
subscribe = async (callbacks: { subscribe = async (callbacks: { onPageChanged?: (page: Page, children: Page[]) => void }) => {
onChildPagesChanged?: (childPages: Page[]) => void; const didUpdateView = (payload: Uint8Array) => {
onPageChanged?: (page: Page) => void; const res = ViewPB.deserializeBinary(payload);
}) => { const page = parserViewPBToPage(ViewPB.deserializeBinary(payload));
const onChanged = async () => { const childPages = res.child_views.map(parserViewPBToPage);
const page = await this.getPage(); callbacks.onPageChanged?.(page, childPages);
const childPages = await this.getChildPages();
callbacks.onPageChanged?.(page);
callbacks.onChildPagesChanged?.(childPages);
}; };
this.onChangeQueue = new AsyncQueue(onChanged);
await this.observer.subscribeView(this.id, { await this.observer.subscribeView(this.id, {
didUpdateChildViews: this.didUpdateChildPages, didUpdateView,
didUpdateView: this.didUpdateView,
}); });
}; };
@ -105,6 +96,16 @@ export class PageController {
return Promise.reject(result.err); 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 () => { deletePage = async () => {
const result = await this.backendService.deletePage(this.id); const result = await this.backendService.deletePage(this.id);
@ -125,12 +126,4 @@ export class PageController {
return Promise.reject(result.err); 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 { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer'; import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { CreateViewPayloadPB } from '@/services/backend'; import { CreateViewPayloadPB, RepeatedViewPB } from "@/services/backend";
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc'; import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
export class WorkspaceController { export class WorkspaceController {
private readonly observer: WorkspaceObserver = new WorkspaceObserver(); private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private readonly pageBackendService: PageBackendService; private readonly pageBackendService: PageBackendService;
private readonly backendService: WorkspaceBackendService; private readonly backendService: WorkspaceBackendService;
private onWorkspaceChanged?: (data: WorkspaceItem) => void;
private onWorkspaceDeleted?: () => void;
private onChangeQueue?: AsyncQueue;
constructor(private readonly workspaceId: string) { constructor(private readonly workspaceId: string) {
this.pageBackendService = new PageBackendService(); this.pageBackendService = new PageBackendService();
this.backendService = new WorkspaceBackendService(); this.backendService = new WorkspaceBackendService();
@ -43,23 +38,15 @@ export class WorkspaceController {
}; };
subscribe = async (callbacks: { subscribe = async (callbacks: {
onWorkspaceChanged?: (data: WorkspaceItem) => void;
onWorkspaceDeleted?: () => void;
onChildPagesChanged?: (childPages: Page[]) => void; onChildPagesChanged?: (childPages: Page[]) => void;
}) => { }) => {
this.onWorkspaceChanged = callbacks.onWorkspaceChanged;
this.onWorkspaceDeleted = callbacks.onWorkspaceDeleted;
const onChildPagesChanged = async () => {
const childPages = await this.getChildPages();
callbacks.onChildPagesChanged?.(childPages); const didUpdateWorkspace = (payload: Uint8Array) => {
}; const res = RepeatedViewPB.deserializeBinary(payload).items;
callbacks.onChildPagesChanged?.(res.map(parserViewPBToPage));
this.onChangeQueue = new AsyncQueue(onChildPagesChanged); }
await this.observer.subscribeWorkspace(this.workspaceId, { await this.observer.subscribeWorkspace(this.workspaceId, {
didUpdateWorkspace: this.didUpdateWorkspace, didUpdateWorkspace
didDeleteWorkspace: this.didDeleteWorkspace,
didUpdateChildViews: this.didUpdateChildPages,
}); });
}; };
@ -85,15 +72,5 @@ export class WorkspaceController {
return []; 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 ( subscribeWorkspace = async (
workspaceId: string, workspaceId: string,
callbacks: { callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void; didUpdateChildViews?: (payload: Uint8Array) => void;
didUpdateWorkspace: (payload: Uint8Array) => void; didUpdateWorkspace?: (payload: Uint8Array) => void;
didDeleteWorkspace: (payload: Uint8Array) => void; didDeleteWorkspace?: (payload: Uint8Array) => void;
} }
) => { ) => {
this.listener = new WorkspaceNotificationObserver({ this.listener = new WorkspaceNotificationObserver({
id: workspaceId, id: workspaceId,
parserHandler: (notification, result) => { parserHandler: (notification, result) => {
switch (notification) { switch (notification) {
case FolderNotification.DidUpdateWorkspace: case FolderNotification.DidUpdateWorkspaceViews:
if (!result.ok) break; if (!result.ok) break;
callbacks.didUpdateWorkspace(result.val); callbacks.didUpdateWorkspace?.(result.val);
break; break;
case FolderNotification.DidUpdateChildViews: case FolderNotification.DidUpdateChildViews:
if (!result.ok) break; if (!result.ok) break;
callbacks.didUpdateChildViews(result.val); callbacks.didUpdateChildViews?.(result.val);
break; break;
// case FolderNotification.DidDeleteWorkspace: // case FolderNotification.DidDeleteWorkspace:
// if (!result.ok) break; // if (!result.ok) break;
@ -58,8 +58,8 @@ export class WorkspaceObserver {
subscribeView = async ( subscribeView = async (
viewId: string, viewId: string,
callbacks: { callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void; didUpdateChildViews?: (payload: Uint8Array) => void;
didUpdateView: (payload: Uint8Array) => void; didUpdateView?: (payload: Uint8Array) => void;
} }
) => { ) => {
this.listener = new WorkspaceNotificationObserver({ this.listener = new WorkspaceNotificationObserver({
@ -68,11 +68,11 @@ export class WorkspaceObserver {
switch (notification) { switch (notification) {
case FolderNotification.DidUpdateChildViews: case FolderNotification.DidUpdateChildViews:
if (!result.ok) break; if (!result.ok) break;
callbacks.didUpdateChildViews(result.val); callbacks.didUpdateChildViews?.(result.val);
break; break;
case FolderNotification.DidUpdateView: case FolderNotification.DidUpdateView:
if (!result.ok) break; if (!result.ok) break;
callbacks.didUpdateView(result.val); callbacks.didUpdateView?.(result.val);
break; break;
default: default:
break; break;

View File

@ -4,17 +4,33 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta'; import Delta, { Op } from 'quill-delta';
import { RootState } from '$app/stores/store'; import { RootState } from '$app/stores/store';
import { DOCUMENT_NAME } from '$app/constants/document/name'; 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( export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta', 'document/updateNodeDelta',
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => { async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
const { id, delta, controller } = payload; const { id, delta, controller } = payload;
const { getState } = thunkAPI; const { getState, dispatch } = thunkAPI;
const state = getState() as RootState; const state = getState() as RootState;
const docId = controller.documentId; const docId = controller.documentId;
const docState = state[DOCUMENT_NAME][docId]; const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id]; 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; if (diffDelta.ops.length === 0) return;

View File

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

View File

@ -1,7 +1,7 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from "@reduxjs/toolkit";
import { DocumentController } from '$app/stores/effects/document/document_controller'; import { DocumentController } from "$app/stores/effects/document/document_controller";
import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document'; import { BlockType, RangeStatic, SplitRelationship } from "$app/interfaces/document";
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to'; import { turnToTextBlockThunk } from "$app_reducers/document/async-actions/turn_to";
import { import {
findNextHasDeltaNode, findNextHasDeltaNode,
findPrevHasDeltaNode, findPrevHasDeltaNode,
@ -9,23 +9,27 @@ import {
getLeftCaretByRange, getLeftCaretByRange,
getRightCaretByRange, getRightCaretByRange,
transformToNextLineCaret, transformToNextLineCaret,
transformToPrevLineCaret, transformToPrevLineCaret
} from '$app/utils/document/action'; } from "$app/utils/document/action";
import Delta from 'quill-delta'; import Delta from "quill-delta";
import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks'; import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from "$app_reducers/document/async-actions/blocks";
import { rangeActions } from '$app_reducers/document/slice'; import { rangeActions } from "$app_reducers/document/slice";
import { RootState } from '$app/stores/store'; import { RootState } from "$app/stores/store";
import { blockConfig } from '$app/constants/document/config'; import { blockConfig } from "$app/constants/document/config";
import { Keyboard } from '$app/constants/document/keyboard'; import { Keyboard } from "$app/constants/document/keyboard";
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; import { DOCUMENT_NAME, RANGE_NAME } from "$app/constants/document/name";
import { getPreviousWordIndex } from '$app/utils/document/delta'; 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 - Deletes a block using the backspace or delete key.
* 1. If the block is not a text block, turn it to a text block - If the block is not a text block, it is converted into a text block.
* 2. If the block is a text block - 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 - - If the block is the first line, it is merged into the document title, and a new line is inserted.
* 2.2 If the block has no next node and is not top level, outdent it - - 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( export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
'document/backspaceDeleteActionForBlock', 'document/backspaceDeleteActionForBlock',
@ -49,11 +53,43 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
} }
const isTopLevel = parent.type === BlockType.PageBlock; 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) { if (isTopLevel || nextNodeId) {
// merge to previous line // merge to previous line
const prevLine = findPrevHasDeltaNode(state, id); const prevLine = findPrevHasDeltaNode(state, id);
if (!prevLine) return; if (!prevLine) return;
const caretIndex = new Delta(prevLine.data.delta).length(); const caretIndex = new Delta(prevLine.data.delta).length();
const caret = { const caret = {
@ -104,19 +140,49 @@ export const enterActionForBlockThunk = createAsyncThunk(
if (!node || !caret || caret.id !== id) return; if (!node || !caret || caret.id !== id) return;
const delta = new Delta(node.data.delta); 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 (delta.length() === 0 && node.type !== BlockType.TextBlock) {
// If the node is not a text block, turn it to a text block // If the node is not a text block, turn it to a text block
await dispatch(turnToTextBlockThunk({ id, controller })); await dispatch(turnToTextBlockThunk({ id, controller }));
return; 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); const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
if (!insertNodeAction) return; if (!insertNodeAction) return;
const updateNode = { const updateNode = {
...node, ...node,
data: { 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 rangeDelta = getDeltaByRange(nodeDelta, selection);
const text = getDeltaText(rangeDelta); const text = getDeltaText(rangeDelta);
const data = newDataWithTemporaryType(type, text);
temporaryState = { temporaryState = {
id, id,
selection, selection,
selectedText: text, selectedText: text,
type, type,
data: { data,
latex: text,
},
}; };
} }
@ -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( export const formatTemporary = createAsyncThunk(
'document/temporary/format', 'document/temporary/format',
async (payload: { controller: DocumentController }, thunkAPI) => { async (payload: { controller: DocumentController }, thunkAPI) => {
@ -69,7 +84,7 @@ export const formatTemporary = createAsyncThunk(
const nodeDelta = new Delta(node.data?.delta); const nodeDelta = new Delta(node.data?.delta);
const { index, length } = selection; const { index, length } = selection;
const diffDelta: Delta = new Delta(); const diffDelta: Delta = new Delta();
let newSelection; let newSelection = selection;
switch (type) { switch (type) {
case TemporaryType.Equation: { case TemporaryType.Equation: {
@ -91,6 +106,21 @@ export const formatTemporary = createAsyncThunk(
break; 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: default:
break; break;

View File

@ -5,21 +5,15 @@ import {
SlashCommandState, SlashCommandState,
RangeState, RangeState,
RangeStatic, RangeStatic,
LinkPopoverState,
SlashCommandOption, SlashCommandOption,
} from '@/appflowy_app/interfaces/document'; } from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend'; import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { parseValue, matchChange } from '$app/utils/document/subscribe'; import { parseValue, matchChange } from '$app/utils/document/subscribe';
import { temporarySlice } from '$app_reducers/document/temporary_slice'; import { temporarySlice } from '$app_reducers/document/temporary_slice';
import { import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '$app/constants/document/name';
DOCUMENT_NAME,
RANGE_NAME,
RECT_RANGE_NAME,
SLASH_COMMAND_NAME,
TEXT_LINK_NAME,
} from '$app/constants/document/name';
import { blockEditSlice } from '$app_reducers/document/block_edit_slice'; import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
import { Op } from 'quill-delta';
const initialState: Record<string, DocumentState> = {}; const initialState: Record<string, DocumentState> = {};
@ -29,8 +23,6 @@ const rangeInitialState: Record<string, RangeState> = {};
const slashCommandInitialState: Record<string, SlashCommandState> = {}; const slashCommandInitialState: Record<string, SlashCommandState> = {};
const linkPopoverState: Record<string, LinkPopoverState> = {};
export const documentSlice = createSlice({ export const documentSlice = createSlice({
name: DOCUMENT_NAME, name: DOCUMENT_NAME,
initialState: initialState, initialState: initialState,
@ -68,6 +60,22 @@ export const documentSlice = createSlice({
children, 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, This function listens for changes in the data layer triggered by the data API,
and updates the UI state accordingly. 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 = { export const documentReducers = {
[documentSlice.name]: documentSlice.reducer, [documentSlice.name]: documentSlice.reducer,
[rectSelectionSlice.name]: rectSelectionSlice.reducer, [rectSelectionSlice.name]: rectSelectionSlice.reducer,
[rangeSlice.name]: rangeSlice.reducer, [rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer, [slashCommandSlice.name]: slashCommandSlice.reducer,
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
[temporarySlice.name]: temporarySlice.reducer, [temporarySlice.name]: temporarySlice.reducer,
[blockEditSlice.name]: blockEditSlice.reducer, [blockEditSlice.name]: blockEditSlice.reducer,
}; };
@ -436,4 +392,3 @@ export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions; export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeActions = rangeSlice.actions; export const rangeActions = rangeSlice.actions;
export const slashCommandActions = slashCommandSlice.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 { RootState } from '$app/stores/store';
import { DragInsertType } from '$app_reducers/block-draggable/slice'; import { DragInsertType } from '$app_reducers/block-draggable/slice';
import { PageController } from '$app/stores/effects/workspace/page/page_controller'; import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { PageIcon } from '$app_reducers/pages/slice';
export const movePageThunk = createAsyncThunk( export const movePageThunk = createAsyncThunk(
'pages/movePage', 'pages/movePage',
@ -56,3 +57,36 @@ export const movePageThunk = createAsyncThunk(
await controller.movePage({ parentId, prevId }); 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'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Page { export interface Page {
@ -6,18 +6,28 @@ export interface Page {
parentId: string; parentId: string;
name: string; name: string;
layout: ViewLayoutPB; layout: ViewLayoutPB;
icon?: string; icon?: PageIcon;
cover?: string;
} }
export function parserViewPBToPage(view: ViewPB) { export interface PageIcon {
ty: ViewIconTypePB;
value: string;
}
export function parserViewPBToPage(view: ViewPB): Page {
const icon = view.icon;
return { return {
id: view.id, id: view.id,
name: view.name, name: view.name,
parentId: view.parent_view_id, parentId: view.parent_view_id,
layout: view.layout, layout: view.layout,
cover: view.cover_url, icon: icon
icon: view.icon_url, ? {
ty: icon.ty,
value: icon.value,
}
: undefined,
}; };
} }
@ -30,7 +40,10 @@ export interface PageState {
export const initialState: PageState = { export const initialState: PageState = {
pageMap: {}, pageMap: {},
relationMap: {}, relationMap: {},
expandedIdMap: {}, expandedIdMap: getExpandedPageIds().reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<string, boolean>),
}; };
export const pagesSlice = createSlice({ export const pagesSlice = createSlice({
@ -75,16 +88,29 @@ export const pagesSlice = createSlice({
expandPage(state, action: PayloadAction<string>) { expandPage(state, action: PayloadAction<string>) {
const id = action.payload; const id = action.payload;
state.expandedIdMap[id] = true; state.expandedIdMap[id] = true;
const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
storeExpandedPageIds(ids);
}, },
collapsePage(state, action: PayloadAction<string>) { collapsePage(state, action: PayloadAction<string>) {
const id = action.payload; const id = action.payload;
state.expandedIdMap[id] = false; state.expandedIdMap[id] = false;
const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
storeExpandedPageIds(ids);
}, },
}, },
}); });
export const pagesActions = pagesSlice.actions; 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'; import emojiData, { EmojiMartData } from '@emoji-mart/data';
export const randomEmoji = () => { export const randomEmoji = (skin = 0) => {
const emojis = (emojiData as EmojiMartData).emojis; const emojis = (emojiData as EmojiMartData).emojis;
const keys = Object.keys(emojis); const keys = Object.keys(emojis);
const randomKey = keys[Math.floor(Math.random() * keys.length)]; 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 { Log } from '../utils/log';
import { import {
documentActions, documentActions,
linkPopoverActions,
rangeActions, rangeActions,
rectSelectionActions, rectSelectionActions,
slashCommandActions, slashCommandActions,
@ -34,7 +33,6 @@ export const useDocument = () => {
dispatch(rangeActions.initialState(docId)); dispatch(rangeActions.initialState(docId));
dispatch(rectSelectionActions.initialState(docId)); dispatch(rectSelectionActions.initialState(docId));
dispatch(slashCommandActions.initialState(docId)); dispatch(slashCommandActions.initialState(docId));
dispatch(linkPopoverActions.initialState(docId));
}, },
[dispatch] [dispatch]
); );
@ -46,7 +44,6 @@ export const useDocument = () => {
dispatch(rangeActions.clear(docId)); dispatch(rangeActions.clear(docId));
dispatch(rectSelectionActions.clear(docId)); dispatch(rectSelectionActions.clear(docId));
dispatch(slashCommandActions.clear(docId)); dispatch(slashCommandActions.clear(docId));
dispatch(linkPopoverActions.clear(docId));
}, },
[dispatch] [dispatch]
); );

View File

@ -15,6 +15,10 @@
background-color: var(--fill-list-active); 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 { .MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
background-image: none; background-image: none;
} }

View File

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

View File

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

View File

@ -38,17 +38,17 @@ opt-level = 3
incremental = false incremental = false
[patch.crates-io] [patch.crates-io]
collab = { 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 = "e9a50f" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" } appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
#
#collab = { path = "../AppFlowy-Collab/collab" } #collab = { path = "../../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../AppFlowy-Collab/collab-folder" } #collab-folder = { path = "../../../AppFlowy-Collab/collab-folder" }
#collab-database= { path = "../AppFlowy-Collab/collab-database" } #collab-database= { path = "../../../AppFlowy-Collab/collab-database" }
#collab-document = { path = "../AppFlowy-Collab/collab-document" } #collab-document = { path = "../../../AppFlowy-Collab/collab-document" }
#collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" } #collab-plugins = { path = "../../../AppFlowy-Collab/collab-plugins" }
#appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" } #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 import;
mod parser; mod parser;
pub mod trash; pub mod trash;
pub mod view; pub mod view;
pub mod workspace; pub mod workspace;
pub use icon::*;
pub use import::*; pub use import::*;
pub use trash::*; pub use trash::*;
pub use view::*; pub use view::*;

View File

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

View File

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

View File

@ -139,6 +139,17 @@ pub(crate) async fn update_view_handler(
Ok(()) 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( pub(crate) async fn delete_view_handler(
data: AFPluginData<RepeatedViewIdPB>, data: AFPluginData<RepeatedViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>, folder: AFPluginState<Weak<FolderManager>>,
@ -233,7 +244,7 @@ pub(crate) async fn read_favorites_handler(
views.push(view); views.push(view);
}, },
Err(err) => { 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::DeleteAllTrash, delete_all_trash_handler)
.event(FolderEvent::ImportData, import_data_handler) .event(FolderEvent::ImportData, import_data_handler)
.event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler) .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler)
.event(FolderEvent::UpdateViewIcon, update_view_icon_handler)
.event(FolderEvent::ReadFavorites, read_favorites_handler) .event(FolderEvent::ReadFavorites, read_favorites_handler)
.event(FolderEvent::ToggleFavorite, toggle_favorites_handler) .event(FolderEvent::ToggleFavorite, toggle_favorites_handler)
} }
@ -149,4 +150,7 @@ pub enum FolderEvent {
#[event(input = "RepeatedViewIdPB")] #[event(input = "RepeatedViewIdPB")]
ToggleFavorite = 34, 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::core::collab_state::SyncState;
use collab_folder::core::{ use collab_folder::core::{
FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace, View, ViewChange, ViewChangeReceiver, ViewLayout, ViewUpdate, Workspace,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use tokio_stream::wrappers::WatchStream; use tokio_stream::wrappers::WatchStream;
@ -18,6 +18,7 @@ use tracing::{event, Level};
use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_folder_deps::cloud::FolderCloudService; use flowy_folder_deps::cloud::FolderCloudService;
use crate::entities::icon::UpdateViewIconParams;
use crate::entities::{ use crate::entities::{
view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, CreateViewParams, view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, CreateViewParams,
CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, FolderSnapshotStatePB, FolderSyncStatePB, CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, FolderSnapshotStatePB, FolderSyncStatePB,
@ -448,7 +449,7 @@ impl FolderManager {
pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> { pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
self.with_folder((), |folder| { self.with_folder((), |folder| {
if let Some(view) = folder.views.get_view(view_id) { 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()]); folder.add_trash(vec![view_id.to_string()]);
// notify the parent view that the view is moved to trash // notify the parent view that the view is moved to trash
send_notification(view_id, FolderNotification::DidMoveViewToTrash) send_notification(view_id, FolderNotification::DidMoveViewToTrash)
@ -587,34 +588,29 @@ impl FolderManager {
/// Update the view with the given params. /// Update the view with the given params.
#[tracing::instrument(level = "trace", skip(self), err)] #[tracing::instrument(level = "trace", skip(self), err)]
pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> { pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> {
let value = self.with_folder(None, |folder| { self
let old_view = folder.views.get_view(&params.view_id); .update_view(&params.view_id, |update| {
let new_view = folder.views.update_view(&params.view_id, |update| {
update update
.set_name_if_not_none(params.name) .set_name_if_not_none(params.name)
.set_desc_if_not_none(params.desc) .set_desc_if_not_none(params.desc)
.set_layout_if_not_none(params.layout) .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) .set_favorite_if_not_none(params.is_favorite)
.done() .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 { /// Update the icon of the view with the given params.
send_notification(&view_pb.id, FolderNotification::DidUpdateView) #[tracing::instrument(level = "trace", skip(self), err)]
.payload(view_pb) pub async fn update_view_icon_with_params(
.send(); &self,
} params: UpdateViewIconParams,
Ok(()) ) -> FlowyResult<()> {
self
.update_view(&params.view_id, |update| {
update.set_icon(params.icon).done()
})
.await
} }
/// Duplicate the view with the given view id. /// Duplicate the view with the given view id.
@ -815,6 +811,32 @@ impl FolderManager {
Ok(view) 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 /// Returns a handler that implements the [FolderOperationHandler] trait
fn get_handler( fn get_handler(
&self, &self,

View File

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

View File

@ -1,5 +1,6 @@
use crate::script::{FolderScript::*, FolderTest}; use crate::script::{FolderScript::*, FolderTest};
use collab_folder::core::ViewLayout; use collab_folder::core::ViewLayout;
use flowy_folder2::entities::icon::{ViewIconPB, ViewIconTypePB};
#[tokio::test] #[tokio::test]
async fn read_all_workspace_test() { async fn read_all_workspace_test() {
@ -152,6 +153,32 @@ async fn view_update() {
assert_eq!(test.child_view.name, new_name); 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] #[tokio::test]
#[should_panic] #[should_panic]
async fn view_delete() { async fn view_delete() {
@ -263,8 +290,8 @@ async fn toggle_favorites() {
ReadView(view.id.clone()), ReadView(view.id.clone()),
]) ])
.await; .await;
assert_eq!(test.child_view.is_favorite, true); assert!(test.child_view.is_favorite);
assert!(test.favorites.len() != 0); assert_ne!(test.favorites.len(), 0);
assert_eq!(test.favorites[0].id, view.id); assert_eq!(test.favorites[0].id, view.id);
let view = test.child_view.clone(); let view = test.child_view.clone();
@ -293,12 +320,12 @@ async fn delete_favorites() {
ReadView(view.id.clone()), ReadView(view.id.clone()),
]) ])
.await; .await;
assert_eq!(test.child_view.is_favorite, true); assert!(test.child_view.is_favorite);
assert!(test.favorites.len() != 0); assert_ne!(test.favorites.len(), 0);
assert_eq!(test.favorites[0].id, view.id); assert_eq!(test.favorites[0].id, view.id);
test.run_scripts(vec![DeleteView, ReadFavorites]).await; test.run_scripts(vec![DeleteView, ReadFavorites]).await;
assert!(test.favorites.len() == 0); assert_eq!(test.favorites.len(), 0);
} }
#[tokio::test] #[tokio::test]

View File

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

View File

@ -16,6 +16,7 @@ use flowy_database2::entities::*;
use flowy_database2::event_map::DatabaseEvent; use flowy_database2::event_map::DatabaseEvent;
use flowy_document2::entities::{DocumentDataPB, OpenDocumentPayloadPB}; use flowy_document2::entities::{DocumentDataPB, OpenDocumentPayloadPB};
use flowy_document2::event_map::DocumentEvent; use flowy_document2::event_map::DocumentEvent;
use flowy_folder2::entities::icon::UpdateViewIconPayloadPB;
use flowy_folder2::entities::*; use flowy_folder2::entities::*;
use flowy_folder2::event_map::FolderEvent; use flowy_folder2::event_map::FolderEvent;
use flowy_notification::entities::SubscribeObject; use flowy_notification::entities::SubscribeObject;
@ -184,6 +185,15 @@ impl FlowyCoreTest {
.error() .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 { pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB {
let payload = CreateViewPayloadPB { let payload = CreateViewPayloadPB {
parent_view_id: parent_id.to_string(), parent_view_id: parent_id.to_string(),
@ -797,7 +807,7 @@ impl Cleaner {
Cleaner(dir) Cleaner(dir)
} }
fn cleanup(dir: &PathBuf) { fn cleanup(_dir: &PathBuf) {
// let _ = std::fs::remove_dir_all(dir); // 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_folder2::entities::*;
use flowy_test::event_builder::EventBuilder; use flowy_test::event_builder::EventBuilder;
use flowy_test::FlowyCoreTest; use flowy_test::FlowyCoreTest;
@ -83,45 +84,27 @@ async fn update_view_event_with_name_test() {
} }
#[tokio::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 test = FlowyCoreTest::new_with_guest_user().await;
let current_workspace = test.get_current_workspace().await.workspace; let current_workspace = test.get_current_workspace().await.workspace;
let view = test let view = test
.create_view(&current_workspace.id, "My first view".to_string()) .create_view(&current_workspace.id, "My first view".to_string())
.await; .await;
let new_icon = ViewIconPB {
ty: ViewIconTypePB::Emoji,
value: "👍".to_owned(),
};
let error = test let error = test
.update_view(UpdateViewPayloadPB { .update_view_icon(UpdateViewIconPayloadPB {
view_id: view.id.clone(), view_id: view.id.clone(),
icon_url: Some("appflowy.io".to_string()), icon: Some(new_icon.clone()),
..Default::default()
}) })
.await; .await;
assert!(error.is_none()); assert!(error.is_none());
let view = test.get_view(&view.id).await; let view = test.get_view(&view.id).await;
assert_eq!(view.icon_url.unwrap(), "appflowy.io"); assert_eq!(view.icon, Some(new_icon));
}
#[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");
} }
#[tokio::test] #[tokio::test]

View File

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

View File

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