init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
@@ -0,0 +1,91 @@
import { useMemo } from 'react';
import style9 from 'style9';
import { CommandMenuCategories } from './config';
type MenuCategoriesProps = {
currentCategories: CommandMenuCategories;
onSetCategories?: (categories: CommandMenuCategories) => void;
categories: Array<CommandMenuCategories>;
};
export const MenuCategories = ({
currentCategories,
onSetCategories,
categories,
}: MenuCategoriesProps) => {
const categories_data = useMemo(() => {
return [
{
type: CommandMenuCategories.pages,
text: 'PAGES',
},
{
type: CommandMenuCategories.typesetting,
text: 'TYPESETTING',
},
{
type: CommandMenuCategories.lists,
text: 'LISTS',
},
{
type: CommandMenuCategories.media,
text: 'MEDIA',
},
{
type: CommandMenuCategories.blocks,
text: 'BLOCKS',
},
];
}, []);
const handle_click = (type: CommandMenuCategories) => {
onSetCategories && onSetCategories(type);
};
return (
<div className={styles('rootContainer')}>
{categories_data.map((menu_category, index) => {
const { type, text } = menu_category;
return categories.includes(type) ? (
<button
className={styles({
categoryItem: true,
activeItem: currentCategories === type,
})}
key={type}
onClick={() => {
handle_click(type);
}}
>
{text}
</button>
) : null;
})}
</div>
);
};
const styles = style9.create({
rootContainer: {
minWidth: '120px',
marginTop: '6px',
},
categoryItem: {
display: 'flex',
width: '120px',
paddingLeft: '12px',
paddingTop: '6px',
paddingBottom: '6px',
borderRadius: '5px',
color: '#98ACBD',
fontSize: '12px',
lineHeight: '14px',
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
textAlign: 'justify',
letterSpacing: '1.5px',
},
activeItem: {
backgroundColor: 'rgba(152, 172, 189, 0.1)',
},
});
@@ -0,0 +1,34 @@
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BasePlugin } from '../../base-plugin';
import { CommandMenu } from './Menu';
const PLUGIN_NAME = 'command-menu';
export class CommandMenuPlugin extends BasePlugin {
private root?: Root;
public static override get pluginName(): string {
return PLUGIN_NAME;
}
protected override on_render(): void {
const container = document.createElement('div');
// TODO remove
container.classList.add(`id-${PLUGIN_NAME}`);
// this.editor.attachElement(this.menu_container);
window.document.body.appendChild(container);
this.root = createRoot(container);
this.render_command_menu();
}
private renderCommandMenu(): void {
//TODO If you change to PluginRenderRoot here, you need to support PluginRenderRoot under body
this.root?.render(
<StrictMode>
<CommandMenu editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
}
@@ -0,0 +1,257 @@
import React, {
useEffect,
useState,
useMemo,
useCallback,
useRef,
} from 'react';
import style9 from 'style9';
import { BlockFlavorKeys } from '@toeverything/datasource/db-service';
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
import {
CommonList,
CommonListItem,
commonListContainer,
} from '@toeverything/components/common';
import { domToRect } from '@toeverything/utils';
import { MenuCategories } from './Categories';
import { menuItemsMap, CommandMenuCategories } from './config';
import { QueryResult } from '../../search';
export type CommandMenuContainerProps = {
editor: Virgo;
hooks: PluginHooks;
style?: React.CSSProperties;
isShow?: boolean;
blockId: string;
onSelected?: (item: BlockFlavorKeys | string) => void;
onclose?: () => void;
searchBlocks?: QueryResult;
types?: Array<BlockFlavorKeys | string>;
categories: Array<CommandMenuCategories>;
};
export const CommandMenuContainer = ({
hooks,
isShow = false,
onSelected,
onclose,
types,
categories,
searchBlocks,
style,
}: CommandMenuContainerProps) => {
const menu_ref = useRef<HTMLDivElement>(null);
const [current_item, set_current_item] = useState<
BlockFlavorKeys | string | undefined
>();
const [need_check_into_view, set_need_check_into_view] =
useState<boolean>(false);
const current_category = useMemo(
() =>
(Object.entries(menuItemsMap).find(
([, infos]) =>
infos.findIndex(info => current_item === info.type) !== -1
)?.[0] || CommandMenuCategories.pages) as CommandMenuCategories,
[current_item]
);
useEffect(() => {
if (need_check_into_view) {
if (current_item && menu_ref.current) {
const item_ele =
menu_ref.current.querySelector<HTMLButtonElement>(
`.item-${current_item}`
);
const scroll_ele =
menu_ref.current.querySelector<HTMLButtonElement>(
`.${commonListContainer}`
);
if (item_ele) {
const itemRect = domToRect(item_ele);
const scrollRect = domToRect(scroll_ele);
if (
itemRect.top < scrollRect.top ||
itemRect.bottom > scrollRect.bottom
) {
// IMP: may be do it with self function
item_ele.scrollIntoView({
block: 'nearest',
});
}
}
}
set_need_check_into_view(false);
}
}, [need_check_into_view, current_item]);
useEffect(() => {
if (isShow && types) {
set_current_item(types[0]);
}
if (!isShow) {
onclose && onclose();
}
}, [isShow]);
useEffect(() => {
if (isShow && types) {
if (!types.includes(current_item)) {
set_need_check_into_view(true);
if (types.length) {
set_current_item(types[0]);
} else {
set_current_item(undefined);
}
}
}
}, [isShow, types, current_item]);
const handle_click_up = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && types && event.code === 'ArrowUp') {
event.preventDefault();
if (!current_item && types.length) {
set_current_item(types[types.length - 1]);
}
if (current_item) {
const idx = types.indexOf(current_item);
if (idx > 0) {
set_need_check_into_view(true);
set_current_item(types[idx - 1]);
}
}
}
},
[isShow, types, current_item]
);
const handle_click_down = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && types && event.code === 'ArrowDown') {
event.preventDefault();
if (!current_item && types.length) {
set_current_item(types[0]);
}
if (current_item) {
const idx = types.indexOf(current_item);
if (idx < types.length - 1) {
set_need_check_into_view(true);
set_current_item(types[idx + 1]);
}
}
}
},
[isShow, types, current_item]
);
const handle_click_enter = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && event.code === 'Enter' && current_item) {
event.preventDefault();
onSelected && onSelected(current_item);
}
},
[isShow, current_item, onSelected]
);
const handle_key_down = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
handle_click_up(event);
handle_click_down(event);
handle_click_enter(event);
},
[handle_click_up, handle_click_down, handle_click_enter]
);
useEffect(() => {
hooks.addHook(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE, handle_key_down);
return () => {
hooks.removeHook(
HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE,
handle_key_down
);
};
}, [hooks, handle_key_down]);
const handleSetCategories = (type: CommandMenuCategories) => {
const newItem = menuItemsMap[type][0].type;
set_need_check_into_view(true);
set_current_item(newItem);
};
const items = useMemo(() => {
const blocks = searchBlocks?.map(
block => ({ block } as CommonListItem)
);
if (blocks?.length) {
blocks.push({ divider: CommandMenuCategories.pages });
}
return [
...(blocks || []),
...Object.entries(menuItemsMap).flatMap(
([category, items], idx, all) => {
let render_separator = false;
const lines: CommonListItem[] = items
.filter(item => types.includes(item.type))
.map(item => {
const { text, type, icon } = item;
render_separator = true;
return {
content: { id: type, content: text, icon },
};
});
if (render_separator && idx !== all.length - 1) {
lines.push({ divider: category });
}
return lines;
}
),
];
}, [searchBlocks, types]);
return isShow ? (
<div
ref={menu_ref}
className={styles('rootContainer')}
onKeyDownCapture={handle_key_down}
style={style}
>
<div className={styles('contentContainer')}>
<MenuCategories
currentCategories={current_category}
onSetCategories={handleSetCategories}
categories={categories}
/>
<CommonList
items={items}
onSelected={type => onSelected?.(type)}
currentItem={current_item}
setCurrentItem={set_current_item}
/>
</div>
</div>
) : null;
};
const styles = style9.create({
rootContainer: {
position: 'fixed',
zIndex: 1,
width: 352,
maxHeight: 525,
borderRadius: '10px',
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
backgroundColor: '#fff',
padding: '8px 4px',
},
contentContainer: {
display: 'flex',
overflow: 'hidden',
maxHeight: 493,
},
});
@@ -0,0 +1,265 @@
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import style9 from 'style9';
import { MuiClickAwayListener } from '@toeverything/components/ui';
import { Virgo, HookType, PluginHooks } from '@toeverything/framework/virgo';
import { Point } from '@toeverything/utils';
import { CommandMenuContainer } from './Container';
import {
CommandMenuCategories,
commandMenuHandlerMap,
commonCommandMenuHandler,
menuItemsMap,
} from './config';
import { QueryBlocks, QueryResult } from '../../search';
export type CommandMenuProps = {
editor: Virgo;
hooks: PluginHooks;
style?: { left: number; top: number };
};
type CommandMenuPosition = {
left: number;
top: number | 'initial';
bottom: number | 'initial';
};
export const CommandMenu = ({ editor, hooks, style }: CommandMenuProps) => {
const [is_show, set_is_show] = useState(false);
const [block_id, set_block_id] = useState<string>();
const [commandMenuPosition, setCommandMenuPosition] =
useState<CommandMenuPosition>({
left: 0,
top: 0,
bottom: 0,
});
const [search_text, set_search_text] = useState<string>('');
const [search_blocks, set_search_blocks] = useState<QueryResult>([]);
const commandMenuContentRef = useRef();
// TODO: Two-way link to be developed
// useEffect(() => {
// QueryBlocks(editor, search_text, result => set_search_blocks(result));
// }, [editor, search_text]);
const [types, categories] = useMemo(() => {
const types: Array<BlockFlavorKeys | string> = [];
const categories: Array<CommandMenuCategories> = [];
if (search_blocks.length) {
Object.values(search_blocks).forEach(({ id }) => types.push(id));
categories.push(CommandMenuCategories.pages);
}
Object.entries(menuItemsMap).forEach(([category, itemInfoList]) => {
itemInfoList.forEach(info => {
if (
!search_text ||
info.text.toLowerCase().includes(search_text.toLowerCase())
) {
types.push(info.type);
}
if (
!categories.includes(category as CommandMenuCategories) ||
types.includes(info.type)
) {
categories.push(category as CommandMenuCategories);
}
});
});
return [types, categories];
}, [search_blocks, search_text]);
const check_if_show_command_menu = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => {
const { type, anchorNode } = editor.selection.currentSelectInfo;
if (event.key === '/' && type === 'Range') {
if (anchorNode) {
const text = editor.blockHelper.getBlockTextBeforeSelection(
anchorNode.id
);
if (text.endsWith('/')) {
set_block_id(anchorNode.id);
editor.blockHelper.removeSearchSlash(block_id);
setTimeout(() => {
const textSelection =
editor.blockHelper.selectionToSlateRange(
anchorNode.id,
editor.selection.currentSelectInfo
.browserSelection
);
if (textSelection) {
const { anchor } = textSelection;
editor.blockHelper.setSearchSlash(
anchorNode.id,
anchor
);
}
});
set_search_text('');
set_is_show(true);
const rect =
editor.selection.currentSelectInfo?.browserSelection
?.getRangeAt(0)
?.getBoundingClientRect();
if (rect) {
let top = rect.top;
let clientHeight =
document.documentElement.clientHeight;
const COMMAND_MENU_HEIGHT = 509;
if (clientHeight - top <= COMMAND_MENU_HEIGHT) {
top = clientHeight - top + 10;
setCommandMenuPosition({
left: rect.left,
bottom: top,
top: 'initial',
});
} else {
top += 24;
setCommandMenuPosition({
left: rect.left,
top: top,
bottom: 'initial',
});
}
}
}
}
}
},
[editor, block_id]
);
const handle_click_others = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (is_show) {
const { anchorNode } = editor.selection.currentSelectInfo;
if (anchorNode.id !== block_id) {
set_is_show(false);
return;
}
setTimeout(() => {
const searchText =
editor.blockHelper.getSearchSlashText(block_id);
// check if has search text
if (searchText && searchText.startsWith('/')) {
set_search_text(searchText.slice(1));
} else {
set_is_show(false);
}
if (searchText.length > 6 && !types.length) {
set_is_show(false);
}
});
}
},
[editor, is_show, block_id, types]
);
const handle_keyup = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
check_if_show_command_menu(event);
handle_click_others(event);
},
[check_if_show_command_menu, handle_click_others]
);
const handle_key_down = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Escape') {
set_is_show(false);
}
},
[]
);
useEffect(() => {
hooks.addHook(HookType.ON_ROOT_NODE_KEYUP, handle_keyup);
hooks.addHook(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE, handle_key_down);
return () => {
hooks.removeHook(HookType.ON_ROOT_NODE_KEYUP, handle_keyup);
hooks.removeHook(
HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE,
handle_key_down
);
};
}, [handle_keyup, handle_key_down, hooks]);
const handle_click_away = () => {
set_is_show(false);
};
const handle_selected = async (type: BlockFlavorKeys | string) => {
const text = await editor.commands.textCommands.getBlockText(block_id);
editor.blockHelper.removeSearchSlash(block_id, true);
if (type.startsWith('Virgo')) {
const handler =
commandMenuHandlerMap[Protocol.Block.Type.reference];
handler(block_id, type, editor);
} else if (text.length > 1) {
const handler = commandMenuHandlerMap[type];
if (handler) {
await handler(block_id, type, editor);
} else {
await commonCommandMenuHandler(block_id, type, editor);
}
const block = await editor.getBlockById(block_id);
block.remove();
} else {
if (Protocol.Block.Type[type as BlockFlavorKeys]) {
const block = await editor.commands.blockCommands.convertBlock(
block_id,
type as BlockFlavorKeys
);
block.firstCreateFlag = true;
}
}
set_is_show(false);
};
const handle_close = () => {
editor.blockHelper.removeSearchSlash(block_id);
};
return (
<div
className={styles('commandMenu')}
onKeyUpCapture={handle_keyup}
ref={commandMenuContentRef}
>
<MuiClickAwayListener onClickAway={handle_click_away}>
<CommandMenuContainer
editor={editor}
hooks={hooks}
style={{
...commandMenuPosition,
...style,
}}
isShow={is_show}
blockId={block_id}
onSelected={handle_selected}
onclose={handle_close}
searchBlocks={search_blocks}
types={types}
categories={categories}
/>
</MuiClickAwayListener>
</div>
);
};
const styles = style9.create({
commandMenu: {
position: 'absolute',
zIndex: 1,
},
});
@@ -0,0 +1,202 @@
import type { FC } from 'react';
import {
HeadingOneIcon,
HeadingTwoIcon,
HeadingThreeIcon,
ToDoIcon,
NumberIcon,
BulletIcon,
CodeIcon,
TextIcon,
PagesIcon,
ImageIcon,
FileIcon,
QuoteIcon,
CalloutIcon,
DividerIcon,
FigmaIcon,
YoutubeIcon,
EmbedIcon,
} from '@toeverything/components/icons';
import { SvgIconProps } from '@toeverything/components/ui';
import { Virgo } from '@toeverything/framework/virgo';
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
import { without } from '@toeverything/utils';
export enum CommandMenuCategories {
pages = 'pages',
typesetting = 'typesetting',
lists = 'lists',
media = 'media',
blocks = 'blocks',
}
type ClickItemHandler = (
anchorNodeId: string,
type: BlockFlavorKeys | string,
editor: Virgo
) => void;
export type CommandMenuDataType = {
type: BlockFlavorKeys;
text: string;
icon: FC<SvgIconProps>;
};
export const commonCommandMenuHandler: ClickItemHandler = async (
anchorNodeId,
type,
editor
) => {
if (anchorNodeId) {
if (Protocol.Block.Type[type as BlockFlavorKeys]) {
const block = await editor.commands.blockCommands.createNextBlock(
anchorNodeId,
type as BlockFlavorKeys
);
editor.selectionManager.activeNodeByNodeId(block.id);
}
}
};
export const menuItemsMap: {
[k in CommandMenuCategories]: Array<CommandMenuDataType>;
} = {
[CommandMenuCategories.pages]: [],
[CommandMenuCategories.typesetting]: [
{
text: 'Text',
type: Protocol.Block.Type.text,
icon: TextIcon,
},
{
text: 'Page',
type: 'page',
icon: PagesIcon,
},
{
text: 'Heading 1',
type: Protocol.Block.Type.heading1,
icon: HeadingOneIcon,
},
{
text: 'Heading 2',
type: Protocol.Block.Type.heading2,
icon: HeadingTwoIcon,
},
{
text: 'Heading 3',
type: Protocol.Block.Type.heading3,
icon: HeadingThreeIcon,
},
],
[CommandMenuCategories.lists]: [
{
text: 'To do',
type: Protocol.Block.Type.todo,
icon: ToDoIcon,
},
{
text: 'Number',
type: Protocol.Block.Type.numbered,
icon: NumberIcon,
},
{
text: 'Bullet',
type: Protocol.Block.Type.bullet,
icon: BulletIcon,
},
],
[CommandMenuCategories.media]: [
{
text: 'Image',
type: Protocol.Block.Type.image,
icon: ImageIcon,
},
{
text: 'File',
type: Protocol.Block.Type.file,
icon: FileIcon,
},
{
text: 'Embed Link',
type: 'embedLink',
icon: EmbedIcon,
},
{
text: 'Figma',
type: Protocol.Block.Type.figma,
icon: FigmaIcon,
},
{
text: 'Youtube',
type: Protocol.Block.Type.youtube,
icon: YoutubeIcon,
},
],
[CommandMenuCategories.blocks]: [
{
text: 'Code',
type: Protocol.Block.Type.code,
icon: CodeIcon,
},
{
text: 'Quote',
icon: QuoteIcon,
type: Protocol.Block.Type.quote,
},
{
text: 'Callout',
type: Protocol.Block.Type.callout,
icon: CalloutIcon,
},
{
text: 'Divider',
icon: DividerIcon,
type: Protocol.Block.Type.divider,
},
],
};
export const defaultCategoriesList = without(
Object.keys(menuItemsMap),
'pages'
) as Array<CommandMenuCategories>;
export const defaultTypeList = Object.values(menuItemsMap).reduce(
(pre, cur) => {
cur.forEach(info => {
pre.push(info.type);
});
return pre;
},
[] as Array<BlockFlavorKeys>
);
export const commandMenuHandlerMap: Partial<{
[k in BlockFlavorKeys | string]: ClickItemHandler;
}> = {
[Protocol.Block.Type.page]: () => {},
[Protocol.Block.Type.reference]: async (anchorNodeId, type, editor) => {
if (anchorNodeId) {
console.log(anchorNodeId, type, editor);
const reflink_block =
await editor.commands.blockCommands.createNextBlock(
anchorNodeId,
Protocol.Block.Type.reference
);
await reflink_block.setProperty('reference', type);
}
},
[Protocol.Block.Type.image]: async (anchorNodeId, type, editor) => {
if (anchorNodeId) {
const image_block =
await editor.commands.blockCommands.createNextBlock(
anchorNodeId,
Protocol.Block.Type.image
);
// set img block active open
image_block.firstCreateFlag = true;
}
},
};
@@ -0,0 +1 @@
export * from './CommandMenu';