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

View File

@@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,7 @@
# components-editor-plugins
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test components-editor-plugins` to execute the unit tests via [Jest](https://jestjs.io).

View File

@@ -0,0 +1,9 @@
module.exports = {
displayName: 'components-editor-plugins',
preset: '../../../jest.preset.js',
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../../coverage/libs/components/editor-plugins',
};

View File

@@ -0,0 +1,10 @@
{
"name": "@toeverything/components/editor-plugins",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@mui/icons-material": "^5.8.4",
"date-fns": "^2.28.0",
"style9": "^0.13.3"
}
}

View File

@@ -0,0 +1,47 @@
{
"sourceRoot": "libs/components/editor-plugins/src",
"projectType": "library",
"tags": ["components:editor-plugins"],
"targets": {
"build": {
"executor": "@nrwl/web:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/components/editor-plugins",
"tsConfig": "libs/components/editor-plugins/tsconfig.lib.json",
"project": "libs/components/editor-plugins/package.json",
"entryFile": "libs/components/editor-plugins/src/index.ts",
"external": ["react/jsx-runtime"],
"rollupConfig": "libs/rollup.config.cjs",
"compiler": "babel",
"assets": [
{
"glob": "libs/components/editor-plugins/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/components/editor-plugins/**/*.{ts,tsx,js,jsx}"
]
}
},
"check": {
"executor": "./tools/executors/tsCheck:tsCheck"
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["coverage/libs/components/editor-plugins"],
"options": {
"jestConfig": "libs/components/editor-plugins/jest.config.js",
"passWithNoTests": true
}
}
}
}

View File

@@ -0,0 +1,85 @@
import {
HookType,
Virgo,
Plugin,
PluginHooks,
} from '@toeverything/framework/virgo';
import { genErrorObj } from '@toeverything/utils';
export abstract class BasePlugin implements Plugin {
protected editor: Virgo;
protected hooks: PluginHooks;
private hook_queue: [type: HookType, fn: (...args: unknown[]) => void][] =
[];
private is_disposed = false;
// Unique identifier to distinguish between different Plugins
public static get pluginName(): string {
throw new Error(
"subclass need to implement 'get pluginName' property accessors."
);
}
// Priority, the higher the number, the higher the priority
public static get priority(): number {
return 1;
}
constructor(editor: Virgo, hooks: PluginHooks) {
this.editor = editor;
// TODO perfect it
this.hooks = {
addHook: (...args) => {
this.hook_queue.push([args[0], args[1]]);
return hooks.addHook(...args);
},
addOnceHook(...args) {
return hooks.addHook(...args);
},
// TODO fix remove
removeHook(...args) {
return hooks.removeHook(...args);
},
};
this.on_render = this.on_render.bind(this);
hooks.addHook(HookType.RENDER, this.on_render, this);
}
/**
* Only executed once during initialization
*/
public init(): void {
// implement in subclass
}
/**
* will trigger multiple times
*/
protected on_render(): void {
// implement in subclass
}
public dispose(): void {
// See https://stackoverflow.com/questions/33387318/access-to-static-properties-via-this-constructor-in-typescript
const pluginName = (this.constructor as typeof BasePlugin).pluginName;
if (this.is_disposed) {
console.warn(`Plugin '${pluginName}' already disposed`);
return;
}
this.is_disposed = true;
// FIX will remove hook multiple times
// if the hook has been removed manually
// or set once flag when add hook
this.hook_queue.forEach(([type, fn]) => {
this.hooks.removeHook(type, fn);
});
this.hook_queue = [];
const errorMsg = `You are trying to access an invalid editor or hooks.
The plugin '${pluginName}' has been disposed.
Make sure all hooks are removed before dispose.`;
this.editor = genErrorObj(errorMsg) as Virgo;
this.hooks = genErrorObj(errorMsg) as PluginHooks;
}
}

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BasePlugin } from '../base-plugin';
import { BlockDomInfo, HookType } from '@toeverything/framework/virgo';
import View from './view';
const PLUGIN_NAME = 'block-property';
export class BlockPropertyPlugin extends BasePlugin {
public static override get pluginName(): string {
return PLUGIN_NAME;
}
private root: Root | undefined;
private root_dom: HTMLElement;
// record mouse moving block id
private current_sliding_block_info: BlockDomInfo;
private is_render = false;
private is_hover = false;
private set_is_hover = (isHover: boolean) => {
this.is_hover = isHover;
};
private insert_root_to_block = async () => {
this.root_dom = document.createElement('div');
this.root_dom.style.position = 'relative';
this.root_dom.style.zIndex = '1000';
this.root_dom.classList.add(`id-${PLUGIN_NAME}`);
this.current_sliding_block_info.dom.appendChild(this.root_dom);
this.root = createRoot(this.root_dom);
};
private on_sliding_block_change = async (blockDomInfo: BlockDomInfo) => {
this.current_sliding_block_info = blockDomInfo;
await this.insert_root_to_block();
this.render_view();
this.is_render = true;
};
private on_mouse_move = async (
event: React.MouseEvent,
blockDomInfo: BlockDomInfo
) => {
if (
blockDomInfo.blockId !== this.current_sliding_block_info?.blockId &&
!this.is_hover
) {
await this.dispose();
await this.on_sliding_block_change(blockDomInfo);
}
};
private render_view = () => {
this.root.render(
<View
blockDomInfo={this.current_sliding_block_info}
setIsHover={this.set_is_hover}
/>
);
};
protected override on_render(): void {
this.hooks.addHook(
HookType.AFTER_ON_NODE_MOUSE_MOVE,
this.on_mouse_move,
this
);
}
override async dispose() {
if (this.current_sliding_block_info) {
this.current_sliding_block_info.dom.removeChild(this.root_dom);
this.current_sliding_block_info = undefined;
}
this.root_dom = undefined;
this.is_render = false;
if (this.root) {
this.root.unmount();
}
}
}

View File

@@ -0,0 +1,67 @@
import React, { StrictMode, useState } from 'react';
import { BlockDomInfo } from '@toeverything/framework/virgo';
import style9 from 'style9';
import { styled } from '@toeverything/components/ui';
import { Add } from '@mui/icons-material';
export default (props: {
blockDomInfo: BlockDomInfo;
setIsHover: (isHover: boolean) => void;
}) => {
const { blockDomInfo, setIsHover } = props;
const [showPopover, setShowPopover] = useState(false);
return (
<StrictMode>
<div
onMouseOver={() => {
setShowPopover(true);
setIsHover(true);
}}
onMouseLeave={() => {
setShowPopover(false);
setIsHover(false);
}}
>
<div className={styles('triggerLine')} />
<div
className={styles('popover', {
popoverShow: showPopover,
})}
>
<Add />
</div>
</div>
</StrictMode>
);
};
const Container = styled('div')({
background: 'blue',
'&:hover .popover': {
background: 'red',
display: 'flex',
},
});
const styles = style9.create({
popover: {
backgroundColor: '#fff',
display: 'none',
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
padding: '8px',
borderRadius: '0 8px 8px 8px',
position: 'absolute',
},
popoverShow: {
display: 'flex',
},
triggerLine: {
position: 'absolute',
bottom: 0,
left: 0,
height: '4px',
width: '20px',
backgroundColor: '#aaa',
cursor: 'pointer',
},
});

View File

@@ -0,0 +1,37 @@
import { styled } from '@toeverything/components/ui';
import { WithEditorSelectionType } from '../menu/inline-menu/types';
import { AddCommentActions } from './AddCommentActions';
import { AddCommentInput } from './AddCommentInput';
import { useAddComment } from './use-add-comment';
export const AddComment = (props: WithEditorSelectionType) => {
const {
currentComment,
setCurrentComment,
createComment,
handleSubmitCurrentComment,
} = useAddComment(props);
return (
<StyledContainerForAddComment>
<AddCommentInput
comment={currentComment}
setComment={setCurrentComment}
createComment={createComment}
handleSubmitCurrentComment={handleSubmitCurrentComment}
/>
<AddCommentActions
{...props}
createComment={createComment}
handleSubmitCurrentComment={handleSubmitCurrentComment}
/>
</StyledContainerForAddComment>
);
};
const StyledContainerForAddComment = styled('div')(({ theme }) => {
return {
// display: 'flex',
margin: theme.affine.spacing.main,
};
});

View File

@@ -0,0 +1,142 @@
import { useCallback, type FC, type MouseEvent } from 'react';
import {
styled,
Tooltip,
IconButton,
type SvgIconProps,
} from '@toeverything/components/ui';
import {
EmbedIcon,
ImageIcon,
ReactionIcon,
CollaboratorIcon,
} from '@toeverything/components/icons';
import { WithEditorSelectionType } from '../menu/inline-menu/types';
import { getEditorMarkForCommentId } from '@toeverything/components/common';
const getCommentQuickActionsData = () => {
return [
{
id: 'attachment',
icon: EmbedIcon,
tooltip: 'Add attachment file',
},
{
id: 'mention',
icon: CollaboratorIcon,
tooltip: 'Mention someone',
},
{
id: 'image',
icon: ImageIcon,
tooltip: 'Add image',
},
{
id: 'emoji',
icon: ReactionIcon,
tooltip: 'Add emoji',
},
];
};
type AddCommentActionsProps = {
createComment: () => Promise<{ commentsId: string } | undefined>;
handleSubmitCurrentComment: () => Promise<void>;
} & WithEditorSelectionType;
export const AddCommentActions = ({
createComment,
handleSubmitCurrentComment,
editor,
selectionInfo,
setShow,
}: AddCommentActionsProps) => {
return (
<StyledContainerForAddCommentActions>
<StyledContainerForActionsButtons>
{getCommentQuickActionsData().map(action => {
const { id, icon, tooltip } = action;
return (
<IconButtonWithTooltip
icon={icon}
tooltip={tooltip}
key={id}
/>
);
})}
<IconButton />
</StyledContainerForActionsButtons>
<StyledContainerForActionsButtons>
<StyledCancelButton
onClick={() => {
setShow(false);
}}
>
Cancel
</StyledCancelButton>
<StyledSendButton onClick={handleSubmitCurrentComment}>
Send
</StyledSendButton>
</StyledContainerForActionsButtons>
</StyledContainerForAddCommentActions>
);
};
type IconButtonWithTooltipProps = {
icon: FC<SvgIconProps>;
tooltip?: string;
};
const IconButtonWithTooltip = ({
icon: Icon,
tooltip,
}: IconButtonWithTooltipProps) => {
return (
<Tooltip content={tooltip} placement="bottom" trigger="hover">
<IconButton aria-label={tooltip}>
<Icon />
</IconButton>
</Tooltip>
);
};
const StyledContainerForAddCommentActions = styled('div')(({ theme }) => {
return {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
};
});
const StyledContainerForActionsButtons = styled('div')(({ theme }) => {
return {
display: 'flex',
};
});
const StyledActionBaseButton = styled('button')(({ theme }) => {
return {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 50,
height: 20,
borderRadius: 5,
fontSize: 12,
};
});
const StyledCancelButton = styled(StyledActionBaseButton)(({ theme }) => {
return {
border: `1px solid ${theme.affine.palette.tagHover}`,
marginRight: theme.affine.spacing.smSpacing,
};
});
const StyledSendButton = styled(StyledActionBaseButton)(({ theme }) => {
return {
border: `none`,
backgroundColor: theme.affine.palette.primary,
color: theme.affine.palette.white,
};
});

View File

@@ -0,0 +1,58 @@
import { useCallback, ChangeEvent, KeyboardEvent } from 'react';
import { styled } from '@toeverything/components/ui';
import { useAddComment } from './use-add-comment';
import { WithEditorSelectionType } from '../menu/inline-menu/types';
type AddCommentInputProps = {
comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>;
createComment: () => Promise<{ commentsId: string }>;
handleSubmitCurrentComment: () => Promise<void>;
};
export const AddCommentInput = (props: AddCommentInputProps) => {
const { comment, setComment, handleSubmitCurrentComment } = props;
const handleTextAreaChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
setComment(event.currentTarget.value);
},
[setComment]
);
// 👀 keydown event won't work as expected
const handleKeyUp = useCallback(
async (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!e.metaKey && !e.shiftKey && e.code === 'Enter' && comment) {
await handleSubmitCurrentComment();
}
},
[comment, handleSubmitCurrentComment]
);
return (
<StyledContainerForAddCommentInput>
{/* <input type="text" placeholder={'Add comment...'} /> */}
<StyledTextArea
placeholder={'Add comment...'}
value={comment || ''}
onChange={handleTextAreaChange}
onKeyUp={handleKeyUp}
/>
</StyledContainerForAddCommentInput>
);
};
const StyledContainerForAddCommentInput = styled('div')(({ theme }) => {
return {
marginLeft: theme.affine.spacing.iconPadding,
};
});
const StyledTextArea = styled('textarea')(({ theme }) => {
return {
minWidth: 252,
resize: 'none',
// color: theme.affine.palette.primaryText,
};
});

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import {
styled,
MuiClickAwayListener as ClickAwayListener,
} from '@toeverything/components/ui';
import {
Virgo,
PluginHooks,
SelectionInfo,
} from '@toeverything/framework/virgo';
import { AddComment } from './AddComment';
export type AddCommentPluginContainerProps = {
editor: Virgo;
hooks: PluginHooks;
style?: { left: number; top: number };
};
export const AddCommentPluginContainer = ({
editor,
hooks,
style,
}: AddCommentPluginContainerProps) => {
const [showAddComment, setShowAddComment] = useState(false);
const [containerStyle, setContainerStyle] = useState<{
left: number;
top: number;
}>(null);
const [selectionInfo, setSelectionInfo] = useState<SelectionInfo>();
useEffect(() => {
const showAddCommentInput = () => {
setShowAddComment(true);
const rect = editor.selection?.currentSelectInfo?.browserSelection
?.getRangeAt(0)
?.getBoundingClientRect();
if (rect) {
setSelectionInfo(editor.selection.currentSelectInfo);
setContainerStyle({ left: rect.left, top: rect.top + 32 });
}
};
editor.plugins.observe('show-add-comment', showAddCommentInput);
return () =>
editor.plugins.unobserve('show-add-comment', showAddCommentInput);
}, [editor.plugins, editor.selection.currentSelectInfo]);
return showAddComment && containerStyle ? (
<ClickAwayListener onClickAway={() => setShowAddComment(false)}>
<StyledContainerForAddCommentContainer style={containerStyle}>
<AddComment
editor={editor}
selectionInfo={selectionInfo}
setShow={setShowAddComment}
/>
</StyledContainerForAddCommentContainer>
</ClickAwayListener>
) : null;
};
const StyledContainerForAddCommentContainer = styled('div')(({ theme }) => {
return {
position: 'fixed',
zIndex: 1,
display: 'flex',
borderRadius: theme.affine.shape.borderRadius,
boxShadow: theme.affine.shadows.shadowSxDownLg,
backgroundColor: theme.affine.palette.white,
};
});

View File

@@ -0,0 +1,40 @@
import { StrictMode } from 'react';
import { BasePlugin } from '../base-plugin';
import { PluginRenderRoot } from '../utils';
import { AddCommentPluginContainer } from './Container';
const PLUGIN_NAME = 'add-comment-plugin';
export class AddCommentPlugin extends BasePlugin {
public static override get pluginName(): string {
return PLUGIN_NAME;
}
private root: PluginRenderRoot;
protected override on_render(): void {
this.root = new PluginRenderRoot({
name: AddCommentPlugin.pluginName,
render: this.editor.reactRenderRoot?.render,
});
this.root.mount();
this.renderAddComment();
}
private renderAddComment(): void {
this.root?.render(
<StrictMode>
<AddCommentPluginContainer
editor={this.editor}
hooks={this.hooks}
/>
</StrictMode>
);
}
public override dispose() {
this.root?.unmount();
super.dispose();
}
}

View File

@@ -0,0 +1 @@
export { AddCommentPlugin } from './Plugin';

View File

@@ -0,0 +1,78 @@
import { useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { services } from '@toeverything/datasource/db-service';
import { WithEditorSelectionType } from '../menu/inline-menu/types';
export const useAddComment = ({
editor,
selectionInfo,
setShow,
}: WithEditorSelectionType) => {
const { workspace_id: workspaceId, page_id: pageId } = useParams();
const [currentComment, setCurrentComment] = useState('');
const createComment = useCallback(async (): Promise<{
commentsId: string;
}> => {
const selectedBlockId = selectionInfo?.anchorNode?.id;
if (!currentComment || !currentComment.trim()) {
throw new Error(
'Comment content must not be empty before creating in db. '
);
}
if (!selectedBlockId) {
throw new Error(
'Commented block id must not be empty before creating in db. '
);
}
const created = await services.api.commentService.createComment({
workspace: workspaceId,
pageId: pageId,
attachedToBlocksIds: [selectionInfo.anchorNode.id],
quote: {
value: [
{
text: editor.blockHelper.getBlockTextBetweenSelection(
selectedBlockId
),
},
],
},
content: {
value: [{ text: currentComment }],
},
});
return created;
}, [
currentComment,
editor.blockHelper,
pageId,
selectionInfo?.anchorNode?.id,
workspaceId,
]);
const handleSubmitCurrentComment = useCallback(async () => {
const textBlockId = selectionInfo.anchorNode.id;
if (!textBlockId) return;
const created = await createComment();
const commentId = created?.commentsId;
if (commentId) {
editor.blockHelper.addComment(textBlockId, commentId);
setShow(false);
}
}, [
createComment,
editor.blockHelper,
selectionInfo.anchorNode.id,
setShow,
]);
return {
currentComment,
setCurrentComment,
createComment,
handleSubmitCurrentComment,
};
};

View File

@@ -0,0 +1,27 @@
import type { PluginCreator } from '@toeverything/framework/virgo';
import {
LeftMenuPlugin,
InlineMenuPlugin,
CommandMenuPlugin,
ReferenceMenuPlugin,
SelectionGroupPlugin,
GroupMenuPlugin,
} from './menu';
import { TemplatePlugin } from './template';
import { FullTextSearchPlugin } from './search';
import { AddCommentPlugin } from './comment';
// import { PlaceholderPlugin } from './placeholder';
// import { BlockPropertyPlugin } from './block-property';
export const plugins: PluginCreator[] = [
FullTextSearchPlugin,
LeftMenuPlugin,
InlineMenuPlugin,
CommandMenuPlugin,
ReferenceMenuPlugin,
TemplatePlugin,
SelectionGroupPlugin,
AddCommentPlugin,
GroupMenuPlugin,
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './CommandMenu';

View File

@@ -0,0 +1,50 @@
import { Button } from '@toeverything/components/common';
import { AsyncBlock, Virgo } from '@toeverything/components/editor-core';
import { HandleParentIcon } from '@toeverything/components/icons';
import { styled } from '@toeverything/components/ui';
import { Point } from '@toeverything/utils';
export const ICON_WIDTH = 24;
type DragItemProps = {
isShow: boolean;
groupBlock: AsyncBlock;
editor: Virgo;
onPositionChange?: (position: Point) => void;
} & React.HTMLAttributes<HTMLDivElement>;
export const DragItem = function ({
isShow,
editor,
groupBlock,
...divProps
}: DragItemProps) {
return (
<StyledDiv {...divProps}>
<StyledButton>
<HandleParentIcon />
</StyledButton>
</StyledDiv>
);
};
const StyledDiv = styled('div')({
padding: '0',
display: 'inlineFlex',
width: `${ICON_WIDTH}px`,
height: `${ICON_WIDTH}px`,
':hover': {
backgroundColor: '#edeef0',
borderRadius: '4px',
},
});
const StyledButton = styled(Button)({
padding: '0',
display: 'inlineFlex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
width: '100%',
height: '100%',
});

View File

@@ -0,0 +1,189 @@
import {
AsyncBlock,
HookType,
PluginHooks,
Virgo,
} from '@toeverything/components/editor-core';
import { domToRect, Point } from '@toeverything/utils';
import { GroupDirection } from '@toeverything/framework/virgo';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DragItem } from './DragItem';
import { Line } from './Line';
import { Menu } from './Menu';
type GroupMenuProps = {
editor?: Virgo;
hooks: PluginHooks;
};
export const GroupMenu = function ({ editor, hooks }: GroupMenuProps) {
const [groupBlock, setGroupBlock] = useState<AsyncBlock | null>(null);
const [showMenu, setShowMenu] = useState(false);
const [position, setPosition] = useState<Point>(new Point(0, 0));
const [dragOverGroup, setDragOverGroup] = useState<AsyncBlock | null>(null);
const [direction, setDirection] = useState<GroupDirection>(
GroupDirection.down
);
const menuRef = useRef<HTMLUListElement>(null);
const handleRootMouseMove = useCallback(
async (e: React.MouseEvent<HTMLDivElement>) => {
const groupBlockNew =
await editor.dragDropManager.getGroupBlockByPoint(
new Point(e.clientX, e.clientY)
);
groupBlockNew && setGroupBlock(groupBlockNew || null);
},
[editor, setGroupBlock]
);
const handleRootMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node)
) {
setShowMenu(false);
}
},
[setShowMenu]
);
const handleRootDragOver = useCallback(
async (e: React.DragEvent<Element>) => {
let groupBlockOnDragOver = null;
const mousePoint = new Point(e.clientX, e.clientY);
if (editor.dragDropManager.isDragGroup(e)) {
groupBlockOnDragOver =
await editor.dragDropManager.getGroupBlockByPoint(
mousePoint
);
if (groupBlockOnDragOver?.id === groupBlock?.id) {
groupBlockOnDragOver = null;
}
}
setDragOverGroup(groupBlockOnDragOver || null);
const direction =
await editor.dragDropManager.checkDragGroupDirection(
groupBlock,
groupBlockOnDragOver,
mousePoint
);
setDirection(direction);
},
[editor, groupBlock]
);
const handleRootDrop = useCallback(
async (e: React.DragEvent<Element>) => {
let groupBlockOnDrop = null;
if (editor.dragDropManager.isDragGroup(e)) {
groupBlockOnDrop =
await editor.dragDropManager.getGroupBlockByPoint(
new Point(e.clientX, e.clientY)
);
if (groupBlockOnDrop?.id === groupBlock?.id) {
groupBlockOnDrop = null;
}
}
},
[editor, groupBlock]
);
const handleRootDragEnd = () => {
setDragOverGroup(null);
};
useEffect(() => {
hooks.addHook(HookType.ON_ROOTNODE_MOUSE_MOVE, handleRootMouseMove);
hooks.addHook(HookType.ON_ROOTNODE_MOUSE_DOWN, handleRootMouseDown);
hooks.addHook(HookType.ON_ROOTNODE_DRAG_OVER, handleRootDragOver);
hooks.addHook(HookType.ON_ROOTNODE_DRAG_END, handleRootDragEnd);
return () => {
hooks.removeHook(
HookType.ON_ROOTNODE_MOUSE_MOVE,
handleRootMouseMove
);
hooks.removeHook(
HookType.ON_ROOTNODE_MOUSE_DOWN,
handleRootMouseDown
);
hooks.removeHook(
HookType.ON_ROOTNODE_DRAG_OVER,
handleRootDragOver
);
hooks.removeHook(HookType.ON_ROOTNODE_DRAG_END, handleRootDragEnd);
};
}, [
hooks,
handleRootMouseMove,
handleRootMouseDown,
handleRootDragOver,
handleRootDrop,
]);
useEffect(() => {
if (groupBlock && groupBlock.dom) {
if (editor.container) {
setPosition(
new Point(
groupBlock.dom.offsetLeft - editor.container.offsetLeft,
groupBlock.dom.offsetTop - editor.container.offsetTop
)
);
}
}
}, [groupBlock, editor]);
const handleClick = () => {
setShowMenu(!showMenu);
};
const handleDragStart = async (e: React.DragEvent<HTMLDivElement>) => {
const dragImage = await editor.blockHelper.getBlockDragImg(
groupBlock.id
);
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 0, 0);
editor.dragDropManager.setDragGroupInfo(e, groupBlock.id);
}
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
useEffect(() => {
setShowMenu(false);
}, [groupBlock]);
return (
<>
{groupBlock ? (
<Menu
editor={editor}
groupBlock={groupBlock}
position={position}
visible={showMenu}
setVisible={setShowMenu}
setGroupBlock={setGroupBlock}
menuRef={menuRef}
>
<DragItem
editor={editor}
isShow={!!groupBlock}
groupBlock={groupBlock}
onClick={handleClick}
onDragStart={handleDragStart}
onMouseDown={handleMouseDown}
draggable={true}
/>
</Menu>
) : null}
<Line
groupBlock={dragOverGroup}
editor={editor}
direction={direction}
/>
</>
);
};

View File

@@ -0,0 +1,63 @@
import {
AsyncBlock,
GroupDirection,
Virgo,
} from '@toeverything/components/editor-core';
import { Rect } from '@toeverything/utils';
import { styled } from '@toeverything/components/ui';
import { useEffect, useState } from 'react';
type LineProps = {
groupBlock: AsyncBlock | null;
editor: Virgo;
direction: GroupDirection;
};
const LINE_WEIGHT = 2;
export const Line = function ({ direction, editor, groupBlock }: LineProps) {
const [isShow, setIsShow] = useState<boolean>(false);
const [rect, setRect] = useState<Rect | null>(null);
useEffect(() => {
if (groupBlock && groupBlock.dom && editor.container) {
setRect(
Rect.fromLWTH(
groupBlock.dom.offsetLeft - editor.container.offsetLeft,
groupBlock.dom.offsetWidth,
groupBlock.dom.offsetTop - editor.container.offsetTop,
groupBlock.dom.offsetHeight
)
);
setIsShow(true);
} else {
setIsShow(false);
}
}, [groupBlock, editor.container]);
const computeLineStyle = (): React.CSSProperties => {
if (!rect) {
return {};
}
return direction === GroupDirection.down
? {
top: rect.bottom + LINE_WEIGHT,
left: rect.left,
width: rect.width,
}
: {
top: rect.top - LINE_WEIGHT,
left: rect.left,
width: rect.width,
};
};
return isShow ? <LineDiv style={computeLineStyle()} /> : null;
};
// TODO: use absolute position
const LineDiv = styled('div')({
zIndex: 2,
position: 'absolute',
background: '#502EC4',
height: LINE_WEIGHT,
});

View File

@@ -0,0 +1,98 @@
import { AsyncBlock, Virgo } from '@toeverything/components/editor-core';
import { DeleteCashBinIcon } from '@toeverything/components/icons';
import { Popover, styled } from '@toeverything/components/ui';
import { Point } from '@toeverything/utils';
import { PropsWithChildren } from 'react';
import { ICON_WIDTH } from './DragItem';
type MenuProps = {
visible: boolean;
setVisible: (visible: boolean) => void;
setGroupBlock: (groupBlock: AsyncBlock | null) => void;
position: Point;
editor: Virgo;
groupBlock: AsyncBlock;
menuRef: React.RefObject<HTMLUListElement>;
};
export const Menu = ({
children,
position,
groupBlock,
setVisible,
visible,
setGroupBlock,
menuRef,
}: PropsWithChildren<MenuProps>) => {
const handlerDeleteGroup = () => {
groupBlock.remove();
setVisible(false);
setGroupBlock(null);
};
const Content = () => {
return (
<MenuUl ref={menuRef}>
<MenuItem onClick={handlerDeleteGroup}>
<IconContainer>
<DeleteCashBinIcon />
</IconContainer>
Delete
</MenuItem>
</MenuUl>
);
};
return (
<Popover
content={<Content />}
placement={'bottom-start'}
visible={visible}
anchorStyle={{
position: 'absolute',
top: position.y,
left: position.x - ICON_WIDTH,
height: ICON_WIDTH,
}}
>
{children}
</Popover>
);
};
const MenuUl = styled('ul')(({ theme }) => ({
fontFamily: 'PingFang SC',
background: '#FFF',
color: '#4C6275',
fontWeight: '400',
}));
const MenuItem = styled('li')(({ theme }) => ({
fontWeight: '400',
width: '268px',
height: '32px',
lineHeight: '32px',
display: 'flex',
fontSize: '14px',
borderRadius: '5px',
cursor: 'pointer',
'&:hover': {
backgroundColor: '#F5F7F8',
},
}));
const IconContainer = styled('div')(({ theme }) => ({
display: 'flex',
height: '32px',
margin: '0 8px',
flexDirection: 'column',
justifyContent: 'center',
lineHeight: '32px',
fontSize: '20px',
'&, & > svg': {
width: '20px',
},
'& > svg': {
height: '20px',
},
}));

View File

@@ -0,0 +1,41 @@
import { StrictMode } from 'react';
import { BasePlugin } from '../../base-plugin';
import { PluginRenderRoot } from '../../utils';
import { GroupMenu } from './GropuMenu';
// import { CommandMenu } from './Menu';
const PLUGIN_NAME = 'group-menu';
export class GroupMenuPlugin extends BasePlugin {
private root?: PluginRenderRoot;
public static override get pluginName(): string {
return PLUGIN_NAME;
}
protected override on_render(): void {
if (this.editor.isWhiteboard) return;
const container = document.createElement('div');
// TODO remove
container.classList.add(`id-${PLUGIN_NAME}`);
this.root = new PluginRenderRoot({
name: PLUGIN_NAME,
render: this.editor.reactRenderRoot.render,
});
this.root.mount();
this._renderGroupMenu();
}
public override dispose() {
this.root?.unmount();
super.dispose();
}
private _renderGroupMenu(): void {
this.root?.render(
<StrictMode>
<GroupMenu editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
}

View File

@@ -0,0 +1 @@
export * from './Plugin';

View File

@@ -0,0 +1,9 @@
export { InlineMenuPlugin } from './inline-menu';
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
export { CommandMenuPlugin } from './command-menu';
export { ReferenceMenuPlugin } from './reference-menu';
export { SelectionGroupPlugin } from './selection-group-menu';
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
export { GroupMenuPlugin } from './group-menu';

View File

@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
import style9 from 'style9';
import {
MuiClickAwayListener as ClickAwayListener,
MuiGrow as Grow,
} from '@toeverything/components/ui';
import {
Virgo,
PluginHooks,
SelectionInfo,
} from '@toeverything/framework/virgo';
import { InlineMenuToolbar } from './Toolbar';
export type InlineMenuContainerProps = {
style?: { left: number; top: number };
editor: Virgo;
hooks: PluginHooks;
};
export const InlineMenuContainer = ({
editor,
style,
hooks,
}: InlineMenuContainerProps) => {
const [showMenu, setShowMenu] = useState(false);
const [containerStyle, setContainerStyle] = useState<{
left: number;
top: number;
}>(null);
const [selectionInfo, setSelectionInfo] = useState<SelectionInfo>();
useEffect(() => {
// const unsubscribe = editor.selection.onSelectionChange(info => {
const unsubscribe = editor.selection.onSelectEnd(info => {
const { type, browserSelection, anchorNode } = info;
if (
type === 'None' ||
!anchorNode ||
!browserSelection ||
browserSelection?.isCollapsed ||
// 👀 inline-toolbar should support more block types except Text
// anchorNode.type !== 'text'
!editor.blockHelper.getCurrentSelection(anchorNode.id) ||
editor.blockHelper.isSelectionCollapsed(anchorNode.id)
) {
return;
}
const rect = browserSelection.getRangeAt(0).getBoundingClientRect();
setSelectionInfo(info);
setShowMenu(true);
setContainerStyle({ left: rect.left, top: rect.top - 64 });
});
return unsubscribe;
}, [editor]);
useEffect(() => {
const hideInlineMenu = () => {
setShowMenu(false);
};
editor.plugins.observe('hide-inline-menu', hideInlineMenu);
return () =>
editor.plugins.unobserve('hide-inline-menu', hideInlineMenu);
}, [editor.plugins]);
return showMenu && containerStyle ? (
<ClickAwayListener onClickAway={() => setShowMenu(false)}>
<Grow
in={showMenu}
style={{ transformOrigin: '0 0 0' }}
{...{ timeout: 'auto' }}
>
<div
style={containerStyle}
className={styles('toolbarContainer')}
onMouseDown={e => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
<InlineMenuToolbar
editor={editor}
selectionInfo={selectionInfo}
setShow={setShowMenu}
/>
</div>
</Grow>
</ClickAwayListener>
) : null;
};
const styles = style9.create({
toolbarContainer: {
position: 'fixed',
zIndex: 1,
display: 'flex',
alignItems: 'center',
padding: '0 12px',
borderRadius: '10px',
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
backgroundColor: '#fff',
},
});

View File

@@ -0,0 +1,37 @@
import { StrictMode } from 'react';
import { BasePlugin } from '../../base-plugin';
import { PluginRenderRoot } from '../../utils';
import { InlineMenuContainer } from './Container';
const PLUGIN_NAME = 'inline-menu';
export class InlineMenuPlugin extends BasePlugin {
public static override get pluginName(): string {
return PLUGIN_NAME;
}
private root: PluginRenderRoot;
protected override on_render(): void {
this.root = new PluginRenderRoot({
name: InlineMenuPlugin.pluginName,
render: this.editor.reactRenderRoot?.render,
});
this.root.mount();
this._renderInlineMenu();
}
private _renderInlineMenu(): void {
this.root?.render(
<StrictMode>
<InlineMenuContainer editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
public override dispose() {
this.root?.unmount();
super.dispose();
}
}

View File

@@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { styled } from '@toeverything/components/ui';
import { useFlag } from '@toeverything/datasource/feature-flags';
import type { WithEditorSelectionType } from './types';
import { getInlineMenuData } from './utils';
import { MenuDropdownItem, MenuIconItem } from './menu-item';
import { INLINE_MENU_UI_TYPES } from './config';
const ToolbarItemSeparator = styled('span')(({ theme }) => ({
display: 'inline-flex',
marginLeft: theme.affine.spacing.xsSpacing,
marginRight: theme.affine.spacing.xsSpacing,
color: theme.affine.palette.menuSeparator,
}));
export const InlineMenuToolbar = ({
editor,
selectionInfo,
setShow,
}: WithEditorSelectionType) => {
// default value is false
const enableCommentFeature = useFlag<boolean>('commentDiscussion');
const inlineMenuData = useMemo(() => {
const data = getInlineMenuData({ enableCommentFeature });
return data;
}, [enableCommentFeature]);
return (
<>
{inlineMenuData.map(menu => {
const { type, name } = menu;
if (type === INLINE_MENU_UI_TYPES.dropdown) {
return (
<MenuDropdownItem
{...menu}
editor={editor}
selectionInfo={selectionInfo}
key={name}
/>
);
}
if (type === INLINE_MENU_UI_TYPES.separator) {
return (
<ToolbarItemSeparator key={name}>
|
</ToolbarItemSeparator>
);
}
if (type === INLINE_MENU_UI_TYPES.icon) {
return (
<MenuIconItem
{...menu}
editor={editor}
selectionInfo={selectionInfo}
setShow={setShow}
key={name}
/>
);
}
return null;
})}
</>
);
};

View File

@@ -0,0 +1,102 @@
import {
fontBgColorPaletteKeys,
fontColorPaletteKeys,
} from '@toeverything/components/common';
import type { InlineMenuNamesType } from './types';
import { Protocol } from '@toeverything/datasource/db-service';
export const INLINE_MENU_UI_TYPES = {
icon: 'Icon',
dropdown: 'Dropdown',
separator: 'Separator',
} as const;
/** inline menu item { key : display-name } */
export const inlineMenuShortcuts = {
textBold: '⌘+B',
textItalic: '⌘+I',
textStrikethrough: '⌘+S',
link: '⌘+K',
[Protocol.Block.Type.code]: '⌘+E',
};
export const inlineMenuNames = {
currentText: 'TEXT SIZE',
[Protocol.Block.Type.heading1]: 'Heading 1',
[Protocol.Block.Type.heading2]: 'Heading 2',
[Protocol.Block.Type.heading3]: 'Heading 3',
text: 'Text',
currentList: 'CHECK BOX',
[Protocol.Block.Type.todo]: 'To do',
[Protocol.Block.Type.numbered]: 'Number',
[Protocol.Block.Type.bullet]: 'Bullet',
comment: 'Comment',
textBold: 'Bold',
textItalic: 'Italic',
textStrikethrough: 'Strikethrough',
link: 'Link',
[Protocol.Block.Type.code]: 'Code',
currentFontColor: 'COLOR',
currentFontBackground: 'BACKGROUND COLOR',
colorDefault: 'Default',
colorGray: 'Gray',
colorBrown: 'Brown',
colorOrange: 'Orange',
colorYellow: 'Yellow',
colorGreen: 'Green',
colorBlue: 'Blue',
colorPurple: 'Purple',
colorPink: 'Pink',
colorRed: 'Red',
bgDefault: 'Default background',
bgGray: 'Gray background',
bgBrown: 'Brown background',
bgOrange: 'Orange background',
bgYellow: 'Yellow background',
bgGreen: 'Green background',
bgBlue: 'Blue background',
bgPurple: 'Purple background',
bgPink: 'Pink background',
bgRed: 'Red background',
currentTextAlign: 'TEXT ALIGN',
alignLeft: 'Align Left',
alignCenter: 'Align Center',
alignRight: 'Align Right',
turnInto: 'TURN INTO',
[Protocol.Block.Type.page]: 'Page',
[Protocol.Block.Type.quote]: 'Quote',
[Protocol.Block.Type.callout]: 'Callout',
// [Protocol.Block.Type.code]: 'Code Block',
codeBlock: 'Code Block',
[Protocol.Block.Type.image]: 'Image',
[Protocol.Block.Type.file]: 'File',
backlinks: 'Backlinks',
moreActions: 'More Actions',
} as const;
export const inlineMenuNamesKeys = Object.keys(inlineMenuNames).reduce(
(aac, curr) => ({ [curr]: curr, ...aac }),
{}
) as Record<InlineMenuNamesType, InlineMenuNamesType>;
export const inlineMenuNamesForFontColor = {
colorDefault: fontColorPaletteKeys.default,
colorGray: fontColorPaletteKeys.affineGray,
colorBrown: fontColorPaletteKeys.affineBrown,
colorOrange: fontColorPaletteKeys.affineOrange,
colorYellow: fontColorPaletteKeys.affineYellow,
colorGreen: fontColorPaletteKeys.affineGreen,
colorBlue: fontColorPaletteKeys.affineBlue,
colorPurple: fontColorPaletteKeys.affinePurple,
colorPink: fontColorPaletteKeys.affinePink,
colorRed: fontColorPaletteKeys.affineRed,
bgDefault: fontBgColorPaletteKeys.default,
bgGray: fontBgColorPaletteKeys.affineGray,
bgBrown: fontBgColorPaletteKeys.affineBrown,
bgOrange: fontBgColorPaletteKeys.affineOrange,
bgYellow: fontBgColorPaletteKeys.affineYellow,
bgGreen: fontBgColorPaletteKeys.affineGreen,
bgBlue: fontBgColorPaletteKeys.affineBlue,
bgPurple: fontBgColorPaletteKeys.affinePurple,
bgPink: fontBgColorPaletteKeys.affinePink,
bgRed: fontBgColorPaletteKeys.affineRed,
} as const;

View File

@@ -0,0 +1 @@
export { InlineMenuPlugin } from './Plugin';

View File

@@ -0,0 +1,196 @@
import React, { useState, useCallback } from 'react';
import style9 from 'style9';
import {
MuiPopover,
styled,
type SvgIconProps,
Tooltip,
} from '@toeverything/components/ui';
import {
fontBgColorPalette,
fontColorPalette,
} from '@toeverything/components/common';
import { ArrowDropDownIcon } from '@toeverything/components/icons';
import type { DropdownItemType, WithEditorSelectionType } from '../types';
import {
inlineMenuNamesKeys,
inlineMenuNamesForFontColor,
inlineMenuShortcuts,
} from '../config';
type MenuDropdownItemProps = DropdownItemType & WithEditorSelectionType;
export const MenuDropdownItem = ({
name,
nameKey,
icon: MenuIcon,
children,
editor,
selectionInfo,
}: MenuDropdownItemProps) => {
const [anchor_ele, set_anchor_ele] = useState<HTMLButtonElement | null>(
null
);
const handle_open_dropdown_menu = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
set_anchor_ele(event.currentTarget);
},
[]
);
const handle_close_dropdown_menu = useCallback(() => {
set_anchor_ele(null);
}, []);
//@ts-ignore
const shortcut = inlineMenuShortcuts[nameKey];
return (
<>
<Tooltip
content={
<div style={{ padding: '2px 4px' }}>
<p>{name}</p>
{shortcut && <p>{shortcut}</p>}
</div>
}
placement="bottom"
trigger="hover"
>
<button
onClick={handle_open_dropdown_menu}
className={styles('currentDropdownButton')}
aria-label={name}
onMouseDown={e => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
<MenuIcon sx={{ width: 20, height: 20 }} />
<ArrowDropDownIcon sx={{ width: 20, height: 20 }} />
</button>
</Tooltip>
<MuiPopover
id={anchor_ele ? 'inline-menu-pop' : undefined}
open={Boolean(anchor_ele)}
anchorEl={anchor_ele}
onClose={handle_close_dropdown_menu}
anchorOrigin={{
horizontal: 'left',
vertical: 40,
}}
>
<div className={styles('dropdownContainer')}>
{children.map(item => {
const {
name,
icon: ItemIcon,
onClick,
nameKey: itemNameKey,
} = item;
const StyledIcon = withStylesForIcon(ItemIcon);
return (
<button
className={styles('dropdownItem')}
key={name}
onClick={() => {
if (
onClick &&
selectionInfo?.anchorNode?.id
) {
onClick({
editor,
type: itemNameKey,
anchorNodeId:
selectionInfo?.anchorNode?.id,
});
}
handle_close_dropdown_menu();
}}
>
<StyledIcon
fontColor={
nameKey ===
inlineMenuNamesKeys.currentFontColor
? fontColorPalette[
inlineMenuNamesForFontColor[
itemNameKey as keyof typeof inlineMenuNamesForFontColor
]
]
: nameKey ===
inlineMenuNamesKeys.currentFontBackground
? fontBgColorPalette[
inlineMenuNamesForFontColor[
itemNameKey as keyof typeof inlineMenuNamesForFontColor
]
]
: ''
}
// fontBgColor={
// nameKey=== inlineMenuNamesKeys.currentFontBackground ? fontBgColorPalette[
// inlineMenuNamesForFontColor[itemNameKey] as keyof typeof fontBgColorPalette
// ]:''
// }
/>
{/* <ItemIcon sx={{ width: 20, height: 20 }} /> */}
<span className={styles('dropdownItemItext')}>
{name}
</span>
</button>
);
})}
</div>
</MuiPopover>
</>
);
};
const withStylesForIcon = (FontIconComponent: React.FC<SvgIconProps>) =>
styled(FontIconComponent, {
shouldForwardProp: (prop: string) =>
!['fontColor', 'fontBgColor'].includes(prop),
})<{ fontColor?: string; fontBgColor?: string }>(
({ fontColor, fontBgColor }) => {
return {
width: 20,
height: 20,
color: fontColor || undefined,
backgroundColor: fontBgColor || undefined,
};
}
);
const styles = style9.create({
currentDropdownButton: {
display: 'inline-flex',
padding: '0',
margin: '15px 6px',
color: '#98acbd',
':hover': { backgroundColor: 'transparent' },
},
dropdownContainer: {
margin: '8px 4px',
},
dropdownItem: {
display: 'flex',
alignItems: 'center',
// @ts-ignore
gap: '12px',
// width: '120px',
height: '32px',
padding: '0px 12px',
borderRadius: '5px',
color: '#98acbd',
':hover': { backgroundColor: '#F5F7F8' },
},
dropdownItemItext: {
color: '#4C6275',
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
},
});

View File

@@ -0,0 +1,70 @@
import React, { useCallback } from 'react';
import style9 from 'style9';
import type { IconItemType, WithEditorSelectionType } from '../types';
import { inlineMenuNamesKeys, inlineMenuShortcuts } from '../config';
import { Tooltip } from '@toeverything/components/ui';
type MenuIconItemProps = IconItemType & WithEditorSelectionType;
export const MenuIconItem = ({
name,
nameKey,
icon: MenuIcon,
onClick,
editor,
selectionInfo,
setShow,
}: MenuIconItemProps) => {
const handleToolbarItemClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (onClick && selectionInfo?.anchorNode?.id) {
onClick({
editor,
type: nameKey,
anchorNodeId: selectionInfo?.anchorNode?.id,
});
}
if ([inlineMenuNamesKeys.comment].includes(nameKey)) {
setShow(false);
}
if (inlineMenuNamesKeys.comment === nameKey) {
editor.plugins.emit('show-add-comment');
}
},
[editor, nameKey, onClick, selectionInfo?.anchorNode?.id, setShow]
);
//@ts-ignore
const shortcut = inlineMenuShortcuts[nameKey];
return (
<Tooltip
content={
<div style={{ padding: '2px 4px' }}>
<p>{name}</p>
{shortcut && <p>{shortcut}</p>}
</div>
}
placement="bottom"
trigger="hover"
>
<button
onClick={handleToolbarItemClick}
className={styles('currentIcon')}
aria-label={name}
>
<MenuIcon sx={{ width: 20, height: 20 }} />
</button>
</Tooltip>
);
};
const styles = style9.create({
currentIcon: {
display: 'inline-flex',
padding: '0',
margin: '15px 6px',
color: '#98acbd',
':hover': { backgroundColor: 'transparent' },
},
});

View File

@@ -0,0 +1,2 @@
export { MenuIconItem } from './IconItem';
export { MenuDropdownItem } from './DropdownItem';

View File

@@ -0,0 +1,50 @@
import React, { type FC } from 'react';
import type { SvgIconProps } from '@toeverything/components/ui';
import type { Virgo, SelectionInfo } from '@toeverything/framework/virgo';
import { inlineMenuNames, INLINE_MENU_UI_TYPES } from './config';
export type WithEditorSelectionType = {
editor: Virgo;
selectionInfo: SelectionInfo;
setShow?: React.Dispatch<React.SetStateAction<boolean>>;
};
export type InlineMenuNamesType = keyof typeof inlineMenuNames;
export type ClickItemHandler = ({
type,
editor,
anchorNodeId,
}: {
type: InlineMenuNamesType;
editor: Virgo;
anchorNodeId: string;
}) => void;
export type IconItemType = {
type: typeof INLINE_MENU_UI_TYPES['icon'];
icon: FC<SvgIconProps>;
nameKey: InlineMenuNamesType;
name: typeof inlineMenuNames[InlineMenuNamesType];
onClick?: ClickItemHandler;
active?: boolean;
};
export type DropdownItemType = {
type: typeof INLINE_MENU_UI_TYPES['dropdown'];
icon: FC<SvgIconProps>;
nameKey: InlineMenuNamesType;
name: typeof inlineMenuNames[InlineMenuNamesType];
children: IconItemType[];
activeKey?: InlineMenuNamesType;
};
type SeparatorMenuItem = {
type: typeof INLINE_MENU_UI_TYPES['separator'];
name: string;
};
export type InlineMenuItem =
| IconItemType
| DropdownItemType
| SeparatorMenuItem;

View File

@@ -0,0 +1,833 @@
/* eslint-disable max-lines */
import {
HeadingOneIcon,
HeadingTwoIcon,
HeadingThreeIcon,
ToDoIcon,
NumberIcon,
BulletIcon,
FormatBoldEmphasisIcon,
FormatItalicIcon,
FormatStrikethroughIcon,
LinkIcon,
CodeIcon,
FormatColorTextIcon,
FormatBackgroundIcon,
AlignLeftIcon,
AlignCenterIcon,
AlignRightIcon,
TurnIntoIcon,
BacklinksIcon,
MoreIcon,
TextFontIcon,
QuoteIcon,
CalloutIcon,
FileIcon,
ImageIcon,
PagesIcon,
CodeBlockIcon,
CommentIcon,
} from '@toeverything/components/icons';
import {
fontBgColorPalette,
fontColorPalette,
type TextAlignOptions,
} from '@toeverything/components/common';
import { Virgo } from '@toeverything/framework/virgo';
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
import { ClickItemHandler, InlineMenuItem } from './types';
import {
inlineMenuNamesKeys,
inlineMenuNamesForFontColor,
INLINE_MENU_UI_TYPES,
inlineMenuNames,
} from './config';
const convert_to_block_type = async ({
editor,
blockId,
blockType,
}: {
editor: Virgo;
blockId: string;
blockType: BlockFlavorKeys;
}) => {
if (Protocol.Block.Type[blockType]) {
await editor.commands.blockCommands.convertBlock(blockId, blockType);
}
};
const toggle_text_format = ({
editor,
nodeId,
format,
}: {
editor: Virgo;
nodeId: string;
format: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'inlinecode';
}) => {
editor.blockHelper.toggleTextFormatBySelection(nodeId, format);
};
const add_link = ({ editor, blockId }: { editor: Virgo; blockId: string }) => {
editor.blockHelper.setLinkModalVisible(blockId, true);
};
const set_paragraph_align = ({
editor,
nodeId,
align,
}: {
editor: Virgo;
nodeId: string;
align: TextAlignOptions;
}) => {
editor.blockHelper.setParagraphAlign(nodeId, align);
};
const set_font_color = ({
editor,
nodeId,
color,
}: {
editor: Virgo;
nodeId: string;
color: keyof typeof fontColorPalette;
}) => {
editor.blockHelper.setTextFontColor(nodeId, color);
};
const set_font_bg_color = ({
editor,
nodeId,
bgColor,
}: {
editor: Virgo;
nodeId: string;
bgColor: keyof typeof fontBgColorPalette;
}) => {
editor.blockHelper.setTextFontBgColor(nodeId, bgColor);
};
const common_handler_for_inline_menu: ClickItemHandler = ({
editor,
anchorNodeId,
type,
}) => {
switch (type) {
case inlineMenuNamesKeys.text:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.text,
});
break;
case inlineMenuNamesKeys.heading1:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.heading1,
});
break;
case inlineMenuNamesKeys.heading2:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.heading2,
});
break;
case inlineMenuNamesKeys.heading3:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.heading3,
});
break;
case inlineMenuNamesKeys.bullet:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.bullet,
});
break;
case inlineMenuNamesKeys.todo:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.todo,
});
break;
case inlineMenuNamesKeys.numbered:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.numbered,
});
break;
case inlineMenuNamesKeys.textBold:
toggle_text_format({
editor,
nodeId: anchorNodeId,
format: 'bold',
});
break;
case inlineMenuNamesKeys.textItalic:
toggle_text_format({
editor,
nodeId: anchorNodeId,
format: 'italic',
});
break;
case inlineMenuNamesKeys.textStrikethrough:
toggle_text_format({
editor,
nodeId: anchorNodeId,
format: 'strikethrough',
});
break;
case inlineMenuNamesKeys.link:
add_link({
editor,
blockId: anchorNodeId,
});
break;
case inlineMenuNamesKeys.code:
toggle_text_format({
editor,
nodeId: anchorNodeId,
format: 'inlinecode',
});
break;
case inlineMenuNamesKeys.alignLeft:
set_paragraph_align({
editor,
nodeId: anchorNodeId,
align: undefined,
});
break;
case inlineMenuNamesKeys.alignCenter:
set_paragraph_align({
editor,
nodeId: anchorNodeId,
align: 'center',
});
break;
case inlineMenuNamesKeys.alignRight:
set_paragraph_align({
editor,
nodeId: anchorNodeId,
align: 'right',
});
break;
case inlineMenuNamesKeys.colorDefault:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorDefault as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorGray:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorGray as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorBrown:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorBrown as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorOrange:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorOrange as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorYellow:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorYellow as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorGreen:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorGreen as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorBlue:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorBlue as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorPurple:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorPurple as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorPink:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorPink as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.colorRed:
set_font_color({
editor,
nodeId: anchorNodeId,
color: inlineMenuNamesForFontColor[
inlineMenuNamesKeys.colorRed as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgDefault:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgDefault as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgGray:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgGray as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgBrown:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgBrown as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgOrange:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgOrange as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgYellow:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgYellow as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgGreen:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgGreen as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgBlue:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgBlue as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgPurple:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgPurple as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgPink:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgPink as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.bgRed:
set_font_bg_color({
editor,
nodeId: anchorNodeId,
bgColor:
inlineMenuNamesForFontColor[
inlineMenuNamesKeys.bgRed as keyof typeof inlineMenuNamesForFontColor
],
});
break;
case inlineMenuNamesKeys.quote:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.quote,
});
break;
case inlineMenuNamesKeys.callout:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.callout,
});
break;
case inlineMenuNamesKeys.codeBlock:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.code,
});
break;
case inlineMenuNamesKeys.image:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.image,
});
break;
case inlineMenuNamesKeys.file:
convert_to_block_type({
editor,
blockId: anchorNodeId,
blockType: Protocol.Block.Type.file,
});
break;
default: // do nothing
}
};
type InlineMenuDataConfigType = {
enableCommentFeature: boolean;
};
export const getInlineMenuData = ({
enableCommentFeature,
}: InlineMenuDataConfigType): InlineMenuItem[] => {
const inlineMenuData = [
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: HeadingOneIcon,
name: inlineMenuNames.currentText,
nameKey: inlineMenuNamesKeys.currentText,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: HeadingOneIcon,
name: inlineMenuNames.heading1,
nameKey: inlineMenuNamesKeys.heading1,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: HeadingTwoIcon,
name: inlineMenuNames.heading2,
nameKey: inlineMenuNamesKeys.heading2,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: HeadingThreeIcon,
name: inlineMenuNames.heading3,
nameKey: inlineMenuNamesKeys.heading3,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: TextFontIcon,
name: inlineMenuNames.text,
nameKey: inlineMenuNamesKeys.text,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: ToDoIcon,
name: inlineMenuNames.currentList,
nameKey: inlineMenuNamesKeys.currentList,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: ToDoIcon,
name: inlineMenuNames.todo,
nameKey: inlineMenuNamesKeys.todo,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: NumberIcon,
name: inlineMenuNames.numbered,
nameKey: inlineMenuNamesKeys.numbered,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: BulletIcon,
name: inlineMenuNames.bullet,
nameKey: inlineMenuNamesKeys.bullet,
active: false,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.separator,
name: 'separator ' + inlineMenuNames.currentList,
},
enableCommentFeature
? {
type: INLINE_MENU_UI_TYPES.icon,
icon: CommentIcon,
name: inlineMenuNames.comment,
nameKey: inlineMenuNamesKeys.comment,
onClick: common_handler_for_inline_menu,
}
: null,
enableCommentFeature
? {
type: INLINE_MENU_UI_TYPES.separator,
name: 'separator ' + inlineMenuNames.comment,
}
: null,
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBoldEmphasisIcon,
name: inlineMenuNames.textBold,
nameKey: inlineMenuNamesKeys.textBold,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatItalicIcon,
name: inlineMenuNames.textItalic,
nameKey: inlineMenuNamesKeys.textItalic,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatStrikethroughIcon,
name: inlineMenuNames.textStrikethrough,
nameKey: inlineMenuNamesKeys.textStrikethrough,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: LinkIcon,
name: inlineMenuNames.link,
nameKey: inlineMenuNamesKeys.link,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: CodeIcon,
name: inlineMenuNames.code,
nameKey: inlineMenuNamesKeys.code,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.separator,
name: 'separator ' + inlineMenuNames.code,
},
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: FormatColorTextIcon,
name: inlineMenuNames.currentFontColor,
nameKey: inlineMenuNamesKeys.currentFontColor,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorDefault,
nameKey: inlineMenuNamesKeys.colorDefault,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorGray,
nameKey: inlineMenuNamesKeys.colorGray,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorBrown,
nameKey: inlineMenuNamesKeys.colorBrown,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorOrange,
nameKey: inlineMenuNamesKeys.colorOrange,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorYellow,
nameKey: inlineMenuNamesKeys.colorYellow,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorGreen,
nameKey: inlineMenuNamesKeys.colorGreen,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorBlue,
nameKey: inlineMenuNamesKeys.colorBlue,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorPurple,
nameKey: inlineMenuNamesKeys.colorPurple,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorPink,
nameKey: inlineMenuNamesKeys.colorPink,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatColorTextIcon,
name: inlineMenuNames.colorRed,
nameKey: inlineMenuNamesKeys.colorRed,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: FormatBackgroundIcon,
name: inlineMenuNames.currentFontBackground,
nameKey: inlineMenuNamesKeys.currentFontBackground,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgDefault,
nameKey: inlineMenuNamesKeys.bgDefault,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgGray,
nameKey: inlineMenuNamesKeys.bgGray,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgBrown,
nameKey: inlineMenuNamesKeys.bgBrown,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgOrange,
nameKey: inlineMenuNamesKeys.bgOrange,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgYellow,
nameKey: inlineMenuNamesKeys.bgYellow,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgGreen,
nameKey: inlineMenuNamesKeys.bgGreen,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgBlue,
nameKey: inlineMenuNamesKeys.bgBlue,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgPurple,
nameKey: inlineMenuNamesKeys.bgPurple,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgPink,
nameKey: inlineMenuNamesKeys.bgPink,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FormatBackgroundIcon,
name: inlineMenuNames.bgRed,
nameKey: inlineMenuNamesKeys.bgRed,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: AlignLeftIcon,
name: inlineMenuNames.currentTextAlign,
nameKey: inlineMenuNamesKeys.currentTextAlign,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: AlignLeftIcon,
name: inlineMenuNames.alignLeft,
nameKey: inlineMenuNamesKeys.alignLeft,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: AlignCenterIcon,
name: inlineMenuNames.alignCenter,
nameKey: inlineMenuNamesKeys.alignCenter,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: AlignRightIcon,
name: inlineMenuNames.alignRight,
nameKey: inlineMenuNamesKeys.alignRight,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.separator,
name: 'separator ' + inlineMenuNames.alignRight,
},
{
type: INLINE_MENU_UI_TYPES.dropdown,
icon: TurnIntoIcon,
name: inlineMenuNames.turnInto,
nameKey: inlineMenuNamesKeys.turnInto,
children: [
{
type: INLINE_MENU_UI_TYPES.icon,
icon: PagesIcon,
name: inlineMenuNames.page,
nameKey: inlineMenuNamesKeys.page,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: QuoteIcon,
name: inlineMenuNames.quote,
nameKey: inlineMenuNamesKeys.quote,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: CalloutIcon,
name: inlineMenuNames.callout,
nameKey: inlineMenuNamesKeys.callout,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: CodeBlockIcon,
name: inlineMenuNames.codeBlock,
nameKey: inlineMenuNamesKeys.codeBlock,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: ImageIcon,
name: inlineMenuNames.image,
nameKey: inlineMenuNamesKeys.image,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: FileIcon,
name: inlineMenuNames.file,
nameKey: inlineMenuNamesKeys.file,
onClick: common_handler_for_inline_menu,
},
],
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: BacklinksIcon,
name: inlineMenuNames.backlinks,
nameKey: inlineMenuNamesKeys.backlinks,
onClick: common_handler_for_inline_menu,
},
{
type: INLINE_MENU_UI_TYPES.icon,
icon: MoreIcon,
name: inlineMenuNames.moreActions,
nameKey: inlineMenuNamesKeys.moreActions,
onClick: common_handler_for_inline_menu,
},
];
return inlineMenuData.filter(item => Boolean(item));
};

View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { Virgo, PluginHooks } from '@toeverything/framework/virgo';
import { Cascader, CascaderItemProps } from '@toeverything/components/ui';
import { TurnIntoMenu } from './TurnIntoMenu';
import {
DeleteCashBinIcon,
TurnIntoIcon,
UngroupIcon,
} from '@toeverything/components/icons';
interface LeftMenuProps {
anchorEl?: Element;
children?: React.ReactElement;
onClose: () => void;
editor?: Virgo;
hooks: PluginHooks;
blockId: string;
}
export function LeftMenu(props: LeftMenuProps) {
const { editor, anchorEl, hooks, blockId } = props;
const menu: CascaderItemProps[] = [
{
title: 'Delete',
callback: () => {
editor.commands.blockCommands.removeBlock(blockId);
},
shortcut: 'Del',
icon: <DeleteCashBinIcon />,
},
{
title: 'Turn into',
subItems: [],
children: (
<TurnIntoMenu
editor={editor}
hooks={hooks}
blockId={blockId}
onClose={() => {
props.onClose();
editor.selection.setSelectedNodesIds([]);
}}
/>
),
icon: <TurnIntoIcon />,
},
{
title: 'Divide Here As A New Group',
icon: <UngroupIcon />,
callback: () => {
editor.commands.blockCommands.splitGroupFromBlock(blockId);
},
},
].filter(v => v);
const [menuList, setMenuList] = useState<CascaderItemProps[]>(menu);
const filter_items = (
value: string,
menuList: CascaderItemProps[],
filterList: CascaderItemProps[]
) => {
menuList.forEach(item => {
if (item?.subItems.length === 0) {
if (item.title.toLocaleLowerCase().indexOf(value) !== -1) {
filterList.push(item);
}
} else {
filter_items(value, item.subItems || [], filterList);
}
});
};
const on_filter = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = e.currentTarget.value;
if (!value) {
setMenuList(menu);
} else {
const filterList: CascaderItemProps[] = [];
filter_items(value.toLocaleLowerCase(), menu, filterList);
setMenuList(
filterList.length > 0 ? filterList : [{ title: 'No Result' }]
);
}
};
return (
<>
{props.children}
<Cascader
items={menuList}
anchorEl={anchorEl}
placement="bottom-start"
open={Boolean(anchorEl)}
onClose={() => props.onClose()}
/>
</>
);
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect, FC } from 'react';
import {
Virgo,
BlockDomInfo,
HookType,
PluginHooks,
BlockDropPlacement,
} from '@toeverything/framework/virgo';
import { Button } from '@toeverything/components/common';
import { styled } from '@toeverything/components/ui';
import { LeftMenu } from './LeftMenu';
import { debounce } from '@toeverything/utils';
import type { Subject } from 'rxjs';
import { HandleChildIcon } from '@toeverything/components/icons';
import { MENU_WIDTH } from './menu-config';
const MENU_BUTTON_OFFSET = 12;
export type LineInfoSubject = Subject<
| {
direction: BlockDropPlacement;
blockInfo: BlockDomInfo;
}
| undefined
>;
export type LeftMenuProps = {
editor: Virgo;
hooks: PluginHooks;
defaultVisible?: boolean;
blockInfo: Subject<BlockDomInfo | undefined>;
lineInfo: LineInfoSubject;
};
type LineInfo = {
direction: BlockDropPlacement;
blockInfo: BlockDomInfo;
};
function Line(props: { lineInfo: LineInfo }) {
const { lineInfo } = props;
if (!lineInfo || lineInfo.direction === BlockDropPlacement.none) {
return null;
}
const { direction, blockInfo } = lineInfo;
const finalDirection = direction;
const lineStyle = {
zIndex: 2,
position: 'absolute' as const,
background: '#502EC4',
};
const intersectionRect = blockInfo.rect;
const horizontalLineStyle = {
...lineStyle,
width: intersectionRect.width,
height: 2,
left: intersectionRect.x - blockInfo.rootRect.x,
};
const topLineStyle = {
...horizontalLineStyle,
top: intersectionRect.top,
};
const bottomLineStyle = {
...horizontalLineStyle,
top: intersectionRect.bottom + 1 - blockInfo.rootRect.y,
};
const verticalLineStyle = {
...lineStyle,
width: 2,
height: intersectionRect.height,
top: intersectionRect.y - blockInfo.rootRect.y,
};
const leftLineStyle = {
...verticalLineStyle,
left: intersectionRect.x - 10 - blockInfo.rootRect.x,
};
const rightLineStyle = {
...verticalLineStyle,
left: intersectionRect.right + 10 - blockInfo.rootRect.x,
};
const styleMap = {
left: leftLineStyle,
right: rightLineStyle,
top: topLineStyle,
bottom: bottomLineStyle,
};
return (
<div className="editor-menu-line" style={styleMap[finalDirection]} />
);
}
function DragComponent(props: {
children: React.ReactNode;
style: React.CSSProperties;
onDragStart: (event: React.DragEvent<Element>) => void;
onDragEnd: (event: React.DragEvent<Element>) => void;
}) {
const { style, children, onDragStart, onDragEnd } = props;
return (
<LigoLeftMenu
draggable
style={style}
onMouseMove={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{children}
</LigoLeftMenu>
);
}
export const LeftMenuDraggable: FC<LeftMenuProps> = props => {
const { editor, blockInfo, defaultVisible, hooks, lineInfo } = props;
const [visible, setVisible] = useState(defaultVisible);
const [anchorEl, setAnchorEl] = useState<Element>();
const [block, setBlock] = useState<BlockDomInfo | undefined>();
const [line, setLine] = useState<LineInfo | undefined>(undefined);
const handleDragStart = (event: React.DragEvent<Element>) => {
window.addEventListener('dragover', handleDragOverCapture, {
capture: true,
});
hooks.addHook(
HookType.ON_ROOTNODE_DRAG_OVER_CAPTURE,
handleDragOverCapture
);
const onDragStart = async (event: React.DragEvent<Element>) => {
if (block == null) return;
const dragImage = await editor.blockHelper.getBlockDragImg(
block.blockId
);
if (dragImage) {
event.dataTransfer.setDragImage(dragImage, -50, -10);
editor.dragDropManager.setDragBlockInfo(event, block.blockId);
}
setVisible(false);
};
onDragStart(event);
event.stopPropagation();
};
const handleDragEnd = (event: React.DragEvent<Element>) => {
event.preventDefault();
window.removeEventListener('dragover', handleDragOverCapture, {
capture: true,
});
setLine(undefined);
};
const onClick = (event: React.MouseEvent) => {
if (block == null) return;
const currentTarget = event.currentTarget;
editor.selection.setSelectedNodesIds([block.blockId]);
setVisible(true);
setAnchorEl(currentTarget);
};
/**
* clear line info
*/
const handleDragOverCapture = debounce((e: MouseEvent) => {
const { target } = e;
if (
target instanceof HTMLElement &&
(!target.closest('[data-block-id]') ||
!editor.container.contains(target))
) {
setLine(undefined);
}
}, 10);
useEffect(() => {
const sub = blockInfo.subscribe(block => {
setBlock(block);
if (block != null) {
setVisible(true);
}
});
return () => sub.unsubscribe();
}, [blockInfo, editor]);
useEffect(() => {
const sub = lineInfo.subscribe(data => {
if (data == null) {
setLine(undefined);
} else {
const { direction, blockInfo } = data;
setLine(prev => {
if (
prev?.blockInfo.blockId !== blockInfo.blockId ||
prev?.direction !== direction
) {
return {
direction,
blockInfo,
};
} else {
return prev;
}
});
}
});
return () => sub.unsubscribe();
}, [editor.dragDropManager, lineInfo]);
return (
<>
{block && (
<DragComponent
style={{
position: 'absolute',
left:
Math.min(
block.rect.left -
MENU_WIDTH -
MENU_BUTTON_OFFSET
) - block.rootRect.left,
top: block.rect.top - block.rootRect.top,
opacity: visible ? 1 : 0,
zIndex: 1,
}}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{
<LeftMenu
anchorEl={anchorEl}
editor={props.editor}
hooks={props.hooks}
onClose={() => setAnchorEl(undefined)}
blockId={block.blockId}
>
<Draggable onClick={onClick}>
<HandleChildIcon />
</Draggable>
</LeftMenu>
}
</DragComponent>
)}
<Line lineInfo={line} />
</>
);
};
const Draggable = styled(Button)({
cursor: 'grab',
padding: '0',
display: 'inlineFlex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
width: '24px',
height: '24px',
':hover': {
backgroundColor: '#edeef0',
borderRadius: '4px',
},
});
const LigoLeftMenu = styled('div')({
backgroundColor: 'transparent',
marginRight: '4px',
});

View File

@@ -0,0 +1,176 @@
import { BlockDomInfo, HookType } from '@toeverything/framework/virgo';
import React, { StrictMode } from 'react';
import { BasePlugin } from '../../base-plugin';
import { ignoreBlockTypes } from './menu-config';
import { LineInfoSubject, LeftMenuDraggable } from './LeftMenuDraggable';
import { PluginRenderRoot } from '../../utils';
import { Subject } from 'rxjs';
import { domToRect, last, Point } from '@toeverything/utils';
export class LeftMenuPlugin extends BasePlugin {
private mousedown?: boolean;
private root?: PluginRenderRoot;
private preBlockId: string;
private hideTimer: number;
private _blockInfo: Subject<BlockDomInfo | undefined> = new Subject();
private _lineInfo: LineInfoSubject = new Subject();
public static override get pluginName(): string {
return 'left-menu';
}
public override init(): void {
this.hooks.addHook(
HookType.AFTER_ON_NODE_MOUSE_MOVE,
this._handleMouseMove
);
this.hooks.addHook(
HookType.ON_ROOTNODE_MOUSE_DOWN,
this._handleMouseDown
);
this.hooks.addHook(
HookType.ON_ROOTNODE_MOUSE_LEAVE,
this._handleRootMouseLeave,
this
);
this.hooks.addHook(HookType.ON_ROOTNODE_MOUSE_UP, this._handleMouseUp);
this.hooks.addHook(
HookType.AFTER_ON_NODE_DRAG_OVER,
this._handleDragOverBlockNode
);
this.hooks.addHook(HookType.ON_ROOT_NODE_KEYDOWN, this._handleKeyDown);
this.hooks.addHook(HookType.ON_ROOTNODE_DROP, this._onDrop);
}
private _handleRootMouseLeave() {
this._hideLeftMenu();
}
private _onDrop = () => {
this.preBlockId = '';
this._lineInfo.next(undefined);
};
private _handleDragOverBlockNode = async (
event: React.DragEvent<Element>,
blockInfo: BlockDomInfo
) => {
const { type, dom, blockId } = blockInfo;
if (this.editor.dragDropManager.isDragBlock(event)) {
event.preventDefault();
if (ignoreBlockTypes.includes(type)) {
return;
}
const direction =
await this.editor.dragDropManager.checkBlockDragTypes(
event,
dom,
blockId
);
this._lineInfo.next({ direction, blockInfo });
}
};
private _handleMouseMove = async (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
node: BlockDomInfo
) => {
if (!this.hideTimer) {
this.hideTimer = window.setTimeout(() => {
if (this.mousedown) {
this._hideLeftMenu();
return;
}
this.hideTimer = 0;
}, 300);
}
if (this.editor.readonly) {
this._hideLeftMenu();
return;
}
if (node.blockId !== this.preBlockId) {
if (node.dom) {
const mousePoint = new Point(e.clientX, e.clientY);
const children = await (
await this.editor.getBlockById(node.blockId)
).children();
// if mouse point is between the first and last child do not show left menu
if (children.length) {
const firstChildren = children[0];
const lastChildren = last(children);
if (firstChildren.dom && lastChildren.dom) {
const firstChildrenRect = domToRect(firstChildren.dom);
const lastChildrenRect = domToRect(lastChildren.dom);
if (
firstChildrenRect.top < mousePoint.y &&
lastChildrenRect.bottom > mousePoint.y
) {
return;
}
}
}
}
this.preBlockId = node.blockId;
this._showLeftMenu(node);
}
};
private _handleMouseUp(
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
node: BlockDomInfo
) {
if (this.hideTimer) {
window.clearTimeout(this.hideTimer);
this.hideTimer = 0;
}
this.mousedown = false;
}
private _handleMouseDown = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
node: BlockDomInfo
) => {
this.mousedown = true;
};
private _hideLeftMenu = (): void => {
this._blockInfo.next(undefined);
};
private _handleKeyDown = () => {
this._hideLeftMenu();
};
private _showLeftMenu = (blockInfo: BlockDomInfo): void => {
if (ignoreBlockTypes.includes(blockInfo.type)) {
return;
}
this._blockInfo.next(blockInfo);
};
protected override on_render(): void {
this.root = new PluginRenderRoot({
name: LeftMenuPlugin.pluginName,
render: (...args) => {
return this.editor.reactRenderRoot?.render(...args);
},
});
this.root.mount();
this.root.render(
<StrictMode>
<LeftMenuDraggable
key={Math.random() + ''}
defaultVisible={true}
editor={this.editor}
hooks={this.hooks}
blockInfo={this._blockInfo}
lineInfo={this._lineInfo}
/>
</StrictMode>
);
}
public override dispose(): void {
// TODO: rxjs
this.root?.unmount();
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
import { Virgo, PluginHooks } from '@toeverything/framework/virgo';
import { CommandMenuContainer } from '../command-menu/Container';
import { defaultCategoriesList, defaultTypeList } from '../command-menu/config';
interface TurnIntoMenuProps {
editor: Virgo;
hooks: PluginHooks;
blockId: string;
onClose?: () => void;
}
export const TurnIntoMenu = ({
blockId,
editor,
hooks,
onClose,
}: TurnIntoMenuProps) => {
const handle_select = (type: string) => {
if (Protocol.Block.Type[type as BlockFlavorKeys]) {
editor.commands.blockCommands.convertBlock(
blockId,
type as BlockFlavorKeys
);
onClose && onClose();
}
};
return (
<CommandMenuContainer
editor={editor}
hooks={hooks}
isShow={true}
blockId={blockId}
onSelected={handle_select}
types={defaultTypeList}
categories={defaultCategoriesList}
style={{ position: 'relative', boxShadow: 'none', padding: '0' }}
/>
);
};

View File

@@ -0,0 +1,104 @@
import { BlockFlavorKeys, Protocol } from '@toeverything/datasource/db-service';
import ShortTextIcon from '@mui/icons-material/ShortText';
import TitleIcon from '@mui/icons-material/Title';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import {
CodeBlockInlineIcon,
PagesIcon,
} from '@toeverything/components/common';
import ListIcon from '@mui/icons-material/List';
export const MENU_WIDTH = 14;
export const pageConvertIconSize = 24;
type MenuItem = {
name: string[];
title: string;
blockType: string;
render?({ renderNode }: { renderNode: any }): void;
icon: typeof ShortTextIcon;
params?: Record<string, any>;
disable?: boolean;
};
const textTypeBlocks: MenuItem[] = [
{
name: ['text'], // name represents the search keyword
title: 'Text',
blockType: Protocol.Block.Type.text,
icon: ShortTextIcon,
},
{
name: ['quote'],
title: 'Quote',
blockType: Protocol.Block.Type.quote,
icon: ShortTextIcon,
},
{
name: ['page'],
title: 'Page',
blockType: Protocol.Block.Type.page,
icon: PagesIcon as any,
},
{
name: ['todo'],
title: 'To-do list',
blockType: Protocol.Block.Type.todo,
icon: ListIcon,
},
{
name: ['heading1'],
title: 'Heading 1',
blockType: Protocol.Block.Type.heading1,
icon: TitleIcon,
},
{
name: ['heading2'],
title: 'Heading 2',
blockType: Protocol.Block.Type.heading2,
icon: TitleIcon,
},
{
name: ['heading3'],
title: 'Heading 3',
blockType: Protocol.Block.Type.heading3,
icon: TitleIcon,
},
{
name: ['code'],
title: 'Code',
blockType: Protocol.Block.Type.code,
icon: CodeBlockInlineIcon as any,
},
{
name: ['bullet'],
title: 'Bullet List',
blockType: Protocol.Block.Type.bullet,
icon: FormatListBulletedIcon,
},
{
name: ['call', 'callout'],
title: 'Callout',
blockType: Protocol.Block.Type.callout,
icon: NotificationsNoneIcon,
},
{
name: ['div', 'divider'],
title: 'Divider',
blockType: Protocol.Block.Type.divider,
icon: HorizontalRuleIcon,
},
];
export const addMenuList = [...textTypeBlocks].filter(v => v);
export const textConvertMenuList = textTypeBlocks;
export const ignoreBlockTypes: BlockFlavorKeys[] = [
Protocol.Block.Type.workspace,
Protocol.Block.Type.page,
Protocol.Block.Type.group,
Protocol.Block.Type.title,
Protocol.Block.Type.grid,
Protocol.Block.Type.gridItem,
];

View File

@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useMemo, 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 { ReferenceMenuContainer } from './container';
import { QueryBlocks, QueryResult } from '../../search';
export type ReferenceMenuProps = {
editor: Virgo;
hooks: PluginHooks;
style?: { left: number; top: number };
};
export type RefLinkComponent = {
type: 'reflink';
reference: string;
};
const BEFORE_REGEX = /\[\[(.*)$/;
export const ReferenceMenu = ({ editor, hooks, style }: ReferenceMenuProps) => {
const [is_show, set_is_show] = useState(false);
const [block_id, set_block_id] = useState<string>();
const [position, set_position] = useState<Point>(new Point(0, 0));
const [search_text, set_search_text] = useState<string>('');
const [search_blocks, set_search_blocks] = useState<QueryResult>([]);
useEffect(() => {
QueryBlocks(editor, search_text, result => set_search_blocks(result));
}, [editor, search_text]);
const search_block_ids = useMemo(
() => Object.values(search_blocks).map(({ id }) => id),
[search_blocks]
);
const handle_search = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => {
const { type, anchorNode } = editor.selection.currentSelectInfo;
if (
type === 'Range' &&
anchorNode &&
editor.blockHelper.isSelectionCollapsed(anchorNode.id)
) {
const text = editor.blockHelper.getBlockTextBeforeSelection(
anchorNode.id
);
const matched = BEFORE_REGEX.exec(text)?.[1];
if (typeof matched === 'string') {
if (event.key === '[') set_is_show(true);
set_block_id(anchorNode.id);
set_search_text(matched);
const rect =
editor.selection.currentSelectInfo?.browserSelection
?.getRangeAt(0)
?.getBoundingClientRect();
if (rect) {
set_position(new Point(rect.left, rect.top + 24));
}
} else if (is_show) {
set_is_show(false);
}
}
},
[editor, is_show]
);
const handle_keyup = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => handle_search(event),
[handle_search]
);
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_selected = async (reference: string) => {
if (block_id) {
const { anchorNode } = editor.selection.currentSelectInfo;
editor.blockHelper.insertReference(
reference,
anchorNode.id,
editor.selection.currentSelectInfo?.browserSelection,
-search_text.length - 2
);
}
set_is_show(false);
};
const handle_close = () => {
editor.blockHelper.removeSearchSlash(block_id);
};
return (
<div
className={styles('referenceMenu')}
style={{ top: position.y, left: position.x }}
onKeyUp={handle_keyup}
>
<MuiClickAwayListener onClickAway={() => set_is_show(false)}>
<div>
<ReferenceMenuContainer
editor={editor}
hooks={hooks}
style={style}
isShow={is_show && !!search_text}
blockId={block_id}
onSelected={handle_selected}
onClose={handle_close}
searchBlocks={search_blocks}
types={search_block_ids}
/>
</div>
</MuiClickAwayListener>
</div>
);
};
const styles = style9.create({
referenceMenu: {
position: 'absolute',
zIndex: 1,
},
});

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import style9 from 'style9';
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
import {
CommonList,
CommonListItem,
commonListContainer,
} from '@toeverything/components/common';
import { domToRect } from '@toeverything/utils';
import { QueryResult } from '../../search';
export type ReferenceMenuContainerProps = {
editor: Virgo;
hooks: PluginHooks;
style?: React.CSSProperties;
isShow?: boolean;
blockId: string;
onSelected?: (item: string) => void;
onClose?: () => void;
searchBlocks?: QueryResult;
types?: Array<string>;
};
export const ReferenceMenuContainer = ({
hooks,
isShow = false,
onSelected,
onClose,
types,
searchBlocks,
style,
}: ReferenceMenuContainerProps) => {
const menu_ref = useRef<HTMLDivElement>(null);
const [current_item, set_current_item] = useState<string | undefined>();
const [need_check_into_view, set_need_check_into_view] =
useState<boolean>(false);
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 && !current_item) set_current_item(types[0]);
if (!isShow) onClose?.();
}, [current_item, isShow, onClose, types]);
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]);
return isShow ? (
<div
ref={menu_ref}
className={styles('rootContainer')}
onKeyDownCapture={handle_key_down}
style={style}
>
<div className={styles('contentContainer')}>
<CommonList
items={
searchBlocks?.map(
block => ({ block } as CommonListItem)
) || []
}
onSelected={type => onSelected?.(type)}
currentItem={current_item}
setCurrentItem={set_current_item}
/>
</div>
</div>
) : null;
};
const styles = style9.create({
rootContainer: {
position: 'fixed',
zIndex: 1,
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,
},
});

View File

@@ -0,0 +1,33 @@
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BasePlugin } from '../../base-plugin';
import { ReferenceMenu } from './ReferenceMenu';
const PLUGIN_NAME = 'reference-menu';
export class ReferenceMenuPlugin 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_reference_menu();
}
private render_reference_menu(): void {
this.root?.render(
<StrictMode>
<ReferenceMenu editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
}

View File

@@ -0,0 +1,118 @@
import {
MuiClickAwayListener as ClickAwayListener,
styled,
} from '@toeverything/components/ui';
import { Protocol } from '@toeverything/datasource/db-service';
import type {
AsyncBlock,
PluginHooks,
Virgo,
} from '@toeverything/framework/virgo';
import {
createContext,
useContext,
useEffect,
useState,
type CSSProperties,
} from 'react';
export type Store =
| {
editor: Virgo;
hooks: PluginHooks;
}
| Record<string, never>;
export const StoreContext = createContext<Store>({});
export const MenuApp = () => {
const { editor } = useContext(StoreContext);
const [show, setShow] = useState<boolean>(false);
const [style, setStyle] = useState<CSSProperties>();
const [selectedNodes, setSelectedNodes] = useState<AsyncBlock[]>([]);
useEffect(() => {
if (!editor) {
console.warn('Failed to found editor! ');
return undefined;
}
const unsubscribe = editor.selection.onSelectEnd(
async ({ type, selectedNodesIds }) => {
if (type !== 'Block' || selectedNodesIds.length <= 1) {
// Not show menu if only one node is selected.
// The user can still create a group with only one block.
return;
}
const selectedNodes = (
await Promise.all(
selectedNodesIds.map(id => editor.getBlockById(id))
)
).filter(Boolean) as AsyncBlock[];
if (
!selectedNodes.every(
node => node.type === Protocol.Block.Type.group
)
) {
return;
}
// Assume the first block is the topmost block
const topmostBlock = selectedNodes[0];
if (!topmostBlock) {
throw new Error(
'Failed to get block, id: ' + selectedNodesIds[0]
);
}
const dom = topmostBlock.dom;
if (!dom) {
return;
}
const rect = dom.getBoundingClientRect();
setSelectedNodes(selectedNodes);
setStyle({
position: 'fixed',
left: rect.right,
top: rect.top,
transform: 'translate(-100%, -100%)',
});
setShow(true);
}
);
return unsubscribe;
}, [editor]);
const handleGroup = () => {
editor.commands.blockCommands.mergeGroup(...selectedNodes);
setShow(false);
};
const handleClickAway = () => {
if (show) {
setShow(false);
}
};
return show ? (
<ClickAwayListener onClickAway={handleClickAway}>
<IconButton style={style} onClick={handleGroup}>
Merge Group
</IconButton>
</ClickAwayListener>
) : null;
};
const IconButton = styled('span')({
padding: '4px 8px',
borderRadius: '4px',
color: '#3E6FDB',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(62, 111, 219, 0.1)',
},
'&.active': {
backgroundColor: 'rgba(62, 111, 219, 0.1)',
},
});

View File

@@ -0,0 +1,36 @@
import { StrictMode } from 'react';
import { BasePlugin } from '../../base-plugin';
import { PluginRenderRoot } from '../../utils';
import { MenuApp, StoreContext } from './MenuApp';
const PLUGIN_NAME = 'selection-group';
export class SelectionGroupPlugin extends BasePlugin {
public static override get pluginName(): string {
return PLUGIN_NAME;
}
private root: PluginRenderRoot | undefined;
protected override on_render() {
this.root = new PluginRenderRoot({
name: SelectionGroupPlugin.pluginName,
render: this.editor.reactRenderRoot?.render,
});
this.root.mount();
this.root.render(
<StrictMode>
<StoreContext.Provider
value={{ editor: this.editor, hooks: this.hooks }}
>
<MenuApp />
</StoreContext.Provider>
</StrictMode>
);
}
public override dispose() {
this.root?.unmount();
super.dispose();
}
}

View File

@@ -0,0 +1 @@
export { SelectionGroupPlugin } from './SelectionGroupPlugin';

View File

@@ -0,0 +1,38 @@
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BasePlugin } from './../base-plugin';
import { PlaceholderPanel } from './PlaceholderPanel';
const PLUGIN_NAME = 'placeholder';
export class PlaceholderPlugin 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}`);
window.document.body.appendChild(container);
this.root = createRoot(container);
this._renderPlugin();
}
private _renderPlugin(): void {
this.root?.render(
<StrictMode>
<PlaceholderPanel
editor={this.editor}
onClickTips={() => this.dispose()}
/>
</StrictMode>
);
}
public override dispose(): void {
this.root?.unmount();
}
}

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Virgo, BlockEditor } from '@toeverything/framework/virgo';
import { debounce } from '@toeverything/utils';
import { MuiBox as Box, styled, BaseButton } from '@toeverything/components/ui';
import {
services,
type ReturnUnobserve,
TemplateFactory,
TemplateMeta,
} from '@toeverything/datasource/db-service';
const PlaceholderPanelContainer = styled('div')({
position: 'fixed',
top: '0px',
fontSize: '16px',
lineHeight: '22px',
opacity: '0.333',
});
const TemplateItemContainer = styled('div')({
'&:hover': {
backgroundColor: '#eee',
cursor: 'pointer',
},
});
const EmptyPageTipContainer = styled('div')({
cursor: 'pointer',
'&:hover': {
backgroundColor: '#eee',
cursor: 'pointer',
},
});
type PlaceholderPanelProps = {
editor: Virgo;
onClickTips?: () => void;
};
export const PlaceholderPanel = (props: PlaceholderPanelProps) => {
const editor = props.editor;
const workspaceId = editor.workspace;
const [point, setPoint] = useState({
x: 0,
y: 0,
});
const [open, setOpen] = useState(false);
// const navigate = useNavigate();
const ifPageChildrenExist = useCallback(async () => {
const rootId = await editor.getRootBlockId();
const dbPageBlock = await services.api.editorBlock.getBlock(
workspaceId,
rootId
);
if (!dbPageBlock) return;
if (dbPageBlock.children && dbPageBlock.children.length > 0) {
setOpen(false);
} else {
if (open) {
return;
}
setOpen(true);
adjustPosition();
}
}, [
editor,
workspaceId,
editor.getRootBlockId,
open,
services.api.editorBlock.getBlock,
]);
useEffect(() => {
let unobserve: ReturnUnobserve | undefined = undefined;
const observe = async () => {
const rootId = await editor.getRootBlockId();
unobserve = await services.api.editorBlock.observe(
{
workspace: workspaceId,
id: rootId,
},
() => {
ifPageChildrenExist();
}
);
};
observe();
return () => {
unobserve?.();
};
}, [editor, workspaceId, editor.getRootBlockId, ifPageChildrenExist]);
const adjustPosition = useCallback(async () => {
//@ts-ignore
const rootBlockDom = await editor.getBlockDomById(
editor.getRootBlockId()
);
const { x, y } = rootBlockDom.getBoundingClientRect();
setPoint({
x,
y: y + 60,
});
//@ts-ignore
}, [editor, editor.getRootBlockId, editor.getBlockDomById]);
useEffect(() => {
let unobserve: () => void;
const observerePageTreeChange = async () => {
unobserve = await services.api.pageTree.observe(
{ workspace: workspaceId, page: editor.getRootBlockId() },
ifPageChildrenExist
);
};
observerePageTreeChange();
return () => {
unobserve?.();
};
}, [editor, workspaceId, editor.getRootBlockId, ifPageChildrenExist, open]);
useEffect(() => {
ifPageChildrenExist();
}, [editor, ifPageChildrenExist, open]);
const handleClickEmptyPage = async () => {
const rootBlockId = await editor.getRootBlockId();
await services.api.editorBlock.copyTemplateToPage(
workspaceId,
rootBlockId,
TemplateFactory.generatePageTemplateByGroupKeys({
name: 'Empty Page',
groupKeys: ['empty'],
})
);
setOpen(false);
props.onClickTips();
};
const templateList: Array<TemplateMeta> =
TemplateFactory.defaultTemplateList;
const handleNewFromTemplate = async (template: TemplateMeta) => {
const pageId = await editor.getRootBlockId();
const newPage = await services.api.editorBlock.getBlock(
workspaceId,
pageId
);
await services.api.editorBlock.copyTemplateToPage(
workspaceId,
newPage.id,
TemplateFactory.generatePageTemplateByGroupKeys(template)
);
// redirectToPage(workspaceId, newPage.id);
// handleClose();
setOpen(false);
};
return (
<>
<PlaceholderPanelContainer
style={{
top: point.y + 'px',
left: point.x + 'px',
display: open ? 'block' : 'none',
}}
>
<EmptyPageTipContainer onClick={handleClickEmptyPage}>
Press Enter to continue with an empty page, or pick a
template
</EmptyPageTipContainer>
<div style={{ marginTop: '4px', marginLeft: '-8px' }}>
{templateList.map((template, index) => {
return (
<TemplateItemContainer
key={index}
onClick={() => {
handleNewFromTemplate(template);
}}
>
<BaseButton>{template.name}</BaseButton>
</TemplateItemContainer>
);
})}
</div>
</PlaceholderPanelContainer>
</>
);
};

View File

@@ -0,0 +1 @@
export * from './Placeholder';

View File

@@ -0,0 +1,50 @@
import { StrictMode } from 'react';
import { HookType } from '@toeverything/framework/virgo';
import { BasePlugin } from '../base-plugin';
import { Search } from './search';
import { PluginRenderRoot } from '../utils';
export class FullTextSearchPlugin extends BasePlugin {
#root?: PluginRenderRoot;
public static override get pluginName(): string {
return 'search';
}
public override init(): void {
this.hooks.addHook(HookType.ON_SEARCH, this.handle_search, this);
}
private unmount() {
if (this.#root) {
this.editor.setHotKeysScope();
this.#root.unmount();
this.#root = undefined;
}
}
private handle_search() {
this.editor.setHotKeysScope('search');
this.render_search();
}
private render_search() {
this.#root = new PluginRenderRoot({
name: FullTextSearchPlugin.pluginName,
render: this.editor.reactRenderRoot.render,
});
this.#root.mount();
this.#root.render(
<StrictMode>
<Search onClose={() => this.unmount()} editor={this.editor} />
</StrictMode>
);
}
public renderSearch() {
this.render_search();
}
}
export type { QueryResult } from './search';
export { QueryBlocks } from './search';

View File

@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import style9 from 'style9';
import { BlockPreview } from '@toeverything/components/common';
import {
TransitionsModal,
MuiBox as Box,
MuiBox,
} from '@toeverything/components/ui';
import { Virgo, BlockEditor } from '@toeverything/framework/virgo';
import { throttle } from '@toeverything/utils';
const styles = style9.create({
wrapper: {
position: 'absolute',
top: '20%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '50vw',
display: 'flex',
flexDirection: 'column',
},
search: {
margin: '0.5em',
backgroundColor: 'white',
boxShadow: '0px 1px 10px rgb(152 172 189 / 60%)',
padding: '16px 32px',
borderRadius: '10px',
},
result: {
margin: '0.5em',
backgroundColor: 'white',
boxShadow: '0px 1px 10px rgb(152 172 189 / 60%)',
padding: '16px 32px',
borderRadius: '10px',
transitionProperty: 'max-height',
transitionDuration: '300ms',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDelay: '0ms',
overflowX: 'hidden',
overflowY: 'hidden',
},
result_hide: {
opacity: 0,
},
});
export type QueryResult = Awaited<ReturnType<BlockEditor['search']>>;
const query_blocks = (
editor: Virgo,
search: string,
callback: (result: QueryResult) => void
) => {
(editor as BlockEditor).search(search).then(pages => callback(pages));
};
export const QueryBlocks = throttle(query_blocks, 500);
type SearchProps = {
onClose: () => void;
editor: Virgo;
};
export const Search = (props: SearchProps) => {
const { workspace_id } = useParams();
const navigate = useNavigate();
const [open, set_open] = useState(true);
const [search, set_search] = useState('');
const [result, set_result] = useState<QueryResult>([]);
useEffect(() => {
QueryBlocks(props.editor, search, result => {
set_result(result);
});
}, [props.editor, search]);
const handle_navigate = useCallback(
(id: string) => navigate(`/${workspace_id}/${id}`),
[navigate, workspace_id]
);
return (
<TransitionsModal
open={open}
onClose={() => {
set_open(false);
props.onClose();
}}
>
<Box className={styles('wrapper')}>
<input
className={styles('search')}
autoFocus
value={search}
onChange={e => set_search(e.target.value)}
/>
<MuiBox
sx={{ maxHeight: `${result.length * 28 + 32 + 20}px` }}
className={styles('result', {
result_hide: !result.length,
})}
>
{result.map(block => (
<BlockPreview
key={block.id}
block={block}
onClick={() => handle_navigate(block.id)}
/>
))}
</MuiBox>
</Box>
</TransitionsModal>
);
};

View File

@@ -0,0 +1 @@
export * from './template';

View File

@@ -0,0 +1,6 @@
ExportJson
```
virgo.plugins.plugins.tempalte.exportTemplateJson()
.then(data => console.log(JSON.stringify(data)));
```

View File

@@ -0,0 +1,107 @@
import { Virgo, AsyncBlock } from '@toeverything/framework/virgo';
import {
Protocol,
services,
Template,
} from '@toeverything/datasource/db-service';
import assert from 'assert';
import format from 'date-fns/format';
import { BasePlugin } from '../base-plugin';
export class TemplatePlugin extends BasePlugin {
static singleton: TemplatePlugin;
public static override get pluginName(): string {
return 'tempalte';
}
async exportTemplate(blockId: string): Promise<Template> {
const editor: Virgo = this.editor;
const curBlock = await editor.getBlockById(
blockId || editor.getRootBlockId()
);
const exportData: Template = {
type: curBlock.type,
properties: curBlock.getProperties(),
blocks: [],
};
const blocks = await curBlock.children();
for (let i = 0; i < blocks.length; i++) {
const subTemplate = await this.exportTemplate(blocks[i].id);
const exportBlock: Template = {
type: blocks[i].type,
properties: blocks[i].getProperties(),
blocks: subTemplate.blocks,
};
exportData.blocks.push(exportBlock);
}
return exportData;
}
private _generateDailyNote_title() {
return `${format(new Date(), 'yyyy/MM/dd')}`;
}
public async addDailyNote(): Promise<AsyncBlock> {
return this._addDailyNote();
}
private async _addDailyNote() {
const newPageBlock = await this.editor.createBlock('page');
newPageBlock.setProperties({
text: { value: [{ text: this._generateDailyNote_title() }] },
});
const nextBlock = await this.editor.createBlock(
Protocol.Block.Type.todo
);
nextBlock.setProperties({
text: { value: [{ text: '1. Get Things Done' }] },
});
newPageBlock.append(nextBlock);
this._addPageIntoWorkspace(newPageBlock);
return newPageBlock;
}
private _getDefaultWorkspaceId() {
return window.location.pathname.split('/')[1];
}
private async _addPageIntoWorkspace(
newPageBlock: AsyncBlock,
targetWorkspaceId?: string
) {
const workspaceId = targetWorkspaceId || this._getDefaultWorkspaceId();
const pageId = newPageBlock.id;
const items = await services.api.pageTree.getPageTree<any>(workspaceId);
await services.api.pageTree.setPageTree<any>(workspaceId, [
{ id: pageId, children: [] },
...items,
]);
}
async imporPageData(importData: Template, targetWorkspaceId: string) {
assert(importData, 'importData is required');
//create new page
const newPageBlock = await this.editor.createBlock('page');
newPageBlock.setProperties(importData.properties);
for (let i = 0; i < importData.blocks.length; i++) {
const nextBlock = await this.editor.createBlock(
importData.blocks[i].type
);
nextBlock.setProperties(importData.blocks[i].properties);
newPageBlock.append(nextBlock);
}
// update page tree
this._addPageIntoWorkspace(newPageBlock);
return newPageBlock.id;
}
async exportTemplateJson(block_id: string) {
return this.exportTemplate(block_id);
}
async imporPageDataJson(json_str: string, targetWorkspaceId: string) {
assert(json_str, 'json_str is required');
const importData: Template = JSON.parse(json_str);
return this.imporPageData(importData, targetWorkspaceId);
}
}

View File

@@ -0,0 +1 @@
export { PluginRenderRoot } from './plugin-render-root';

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import type { PatchNode, UnPatchNode } from '@toeverything/components/ui';
interface PluginRenderRootProps {
name: string;
render: PatchNode;
}
export class PluginRenderRoot {
readonly name: string;
readonly patch: PatchNode;
private un_patch: UnPatchNode;
private root: ReactNode;
public isMounted = false;
constructor({ name, render }: PluginRenderRootProps) {
this.name = name;
this.patch = render;
}
render(node?: ReactNode) {
this.root = node;
if (this.isMounted) {
this.mount();
}
}
mount() {
this.un_patch = this.patch(this.name, this.root);
this.isMounted = true;
}
unmount() {
this.un_patch?.();
this.isMounted = false;
}
}

View File

@@ -0,0 +1,26 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strictNullChecks": false
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": ["node"]
},
"files": [
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}