mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
12
libs/components/editor-plugins/.babelrc
Normal file
12
libs/components/editor-plugins/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
18
libs/components/editor-plugins/.eslintrc.json
Normal file
18
libs/components/editor-plugins/.eslintrc.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
libs/components/editor-plugins/README.md
Normal file
7
libs/components/editor-plugins/README.md
Normal 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).
|
||||
9
libs/components/editor-plugins/jest.config.js
Normal file
9
libs/components/editor-plugins/jest.config.js
Normal 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',
|
||||
};
|
||||
10
libs/components/editor-plugins/package.json
Normal file
10
libs/components/editor-plugins/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
libs/components/editor-plugins/project.json
Normal file
47
libs/components/editor-plugins/project.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
libs/components/editor-plugins/src/base-plugin.ts
Normal file
85
libs/components/editor-plugins/src/base-plugin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
83
libs/components/editor-plugins/src/block-property/index.tsx
Normal file
83
libs/components/editor-plugins/src/block-property/index.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
67
libs/components/editor-plugins/src/block-property/view.tsx
Normal file
67
libs/components/editor-plugins/src/block-property/view.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
37
libs/components/editor-plugins/src/comment/AddComment.tsx
Normal file
37
libs/components/editor-plugins/src/comment/AddComment.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
142
libs/components/editor-plugins/src/comment/AddCommentActions.tsx
Normal file
142
libs/components/editor-plugins/src/comment/AddCommentActions.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
70
libs/components/editor-plugins/src/comment/Container.tsx
Normal file
70
libs/components/editor-plugins/src/comment/Container.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
40
libs/components/editor-plugins/src/comment/Plugin.tsx
Normal file
40
libs/components/editor-plugins/src/comment/Plugin.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
1
libs/components/editor-plugins/src/comment/index.ts
Normal file
1
libs/components/editor-plugins/src/comment/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AddCommentPlugin } from './Plugin';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
0
libs/components/editor-plugins/src/comment/utils.ts
Normal file
0
libs/components/editor-plugins/src/comment/utils.ts
Normal file
27
libs/components/editor-plugins/src/index.ts
Normal file
27
libs/components/editor-plugins/src/index.ts
Normal 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,
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react';
|
||||
import style9 from 'style9';
|
||||
|
||||
import { CommandMenuCategories } from './config';
|
||||
|
||||
type MenuCategoriesProps = {
|
||||
currentCategories: CommandMenuCategories;
|
||||
onSetCategories?: (categories: CommandMenuCategories) => void;
|
||||
categories: Array<CommandMenuCategories>;
|
||||
};
|
||||
|
||||
export const MenuCategories = ({
|
||||
currentCategories,
|
||||
onSetCategories,
|
||||
categories,
|
||||
}: MenuCategoriesProps) => {
|
||||
const categories_data = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
type: CommandMenuCategories.pages,
|
||||
text: 'PAGES',
|
||||
},
|
||||
{
|
||||
type: CommandMenuCategories.typesetting,
|
||||
text: 'TYPESETTING',
|
||||
},
|
||||
{
|
||||
type: CommandMenuCategories.lists,
|
||||
text: 'LISTS',
|
||||
},
|
||||
{
|
||||
type: CommandMenuCategories.media,
|
||||
text: 'MEDIA',
|
||||
},
|
||||
{
|
||||
type: CommandMenuCategories.blocks,
|
||||
text: 'BLOCKS',
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const handle_click = (type: CommandMenuCategories) => {
|
||||
onSetCategories && onSetCategories(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles('rootContainer')}>
|
||||
{categories_data.map((menu_category, index) => {
|
||||
const { type, text } = menu_category;
|
||||
return categories.includes(type) ? (
|
||||
<button
|
||||
className={styles({
|
||||
categoryItem: true,
|
||||
activeItem: currentCategories === type,
|
||||
})}
|
||||
key={type}
|
||||
onClick={() => {
|
||||
handle_click(type);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = style9.create({
|
||||
rootContainer: {
|
||||
minWidth: '120px',
|
||||
marginTop: '6px',
|
||||
},
|
||||
categoryItem: {
|
||||
display: 'flex',
|
||||
width: '120px',
|
||||
paddingLeft: '12px',
|
||||
paddingTop: '6px',
|
||||
paddingBottom: '6px',
|
||||
borderRadius: '5px',
|
||||
color: '#98ACBD',
|
||||
fontSize: '12px',
|
||||
lineHeight: '14px',
|
||||
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
|
||||
textAlign: 'justify',
|
||||
letterSpacing: '1.5px',
|
||||
},
|
||||
activeItem: {
|
||||
backgroundColor: 'rgba(152, 172, 189, 0.1)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { BasePlugin } from '../../base-plugin';
|
||||
import { CommandMenu } from './Menu';
|
||||
|
||||
const PLUGIN_NAME = 'command-menu';
|
||||
|
||||
export class CommandMenuPlugin extends BasePlugin {
|
||||
private root?: Root;
|
||||
|
||||
public static override get pluginName(): string {
|
||||
return PLUGIN_NAME;
|
||||
}
|
||||
|
||||
protected override on_render(): void {
|
||||
const container = document.createElement('div');
|
||||
// TODO remove
|
||||
container.classList.add(`id-${PLUGIN_NAME}`);
|
||||
// this.editor.attachElement(this.menu_container);
|
||||
window.document.body.appendChild(container);
|
||||
this.root = createRoot(container);
|
||||
this.render_command_menu();
|
||||
}
|
||||
|
||||
private renderCommandMenu(): void {
|
||||
//TODO If you change to PluginRenderRoot here, you need to support PluginRenderRoot under body
|
||||
this.root?.render(
|
||||
<StrictMode>
|
||||
<CommandMenu editor={this.editor} hooks={this.hooks} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import style9 from 'style9';
|
||||
|
||||
import { BlockFlavorKeys } from '@toeverything/datasource/db-service';
|
||||
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
CommonList,
|
||||
CommonListItem,
|
||||
commonListContainer,
|
||||
} from '@toeverything/components/common';
|
||||
import { domToRect } from '@toeverything/utils';
|
||||
|
||||
import { MenuCategories } from './Categories';
|
||||
import { menuItemsMap, CommandMenuCategories } from './config';
|
||||
import { QueryResult } from '../../search';
|
||||
|
||||
export type CommandMenuContainerProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
style?: React.CSSProperties;
|
||||
isShow?: boolean;
|
||||
blockId: string;
|
||||
onSelected?: (item: BlockFlavorKeys | string) => void;
|
||||
onclose?: () => void;
|
||||
searchBlocks?: QueryResult;
|
||||
types?: Array<BlockFlavorKeys | string>;
|
||||
categories: Array<CommandMenuCategories>;
|
||||
};
|
||||
|
||||
export const CommandMenuContainer = ({
|
||||
hooks,
|
||||
isShow = false,
|
||||
onSelected,
|
||||
onclose,
|
||||
types,
|
||||
categories,
|
||||
searchBlocks,
|
||||
style,
|
||||
}: CommandMenuContainerProps) => {
|
||||
const menu_ref = useRef<HTMLDivElement>(null);
|
||||
const [current_item, set_current_item] = useState<
|
||||
BlockFlavorKeys | string | undefined
|
||||
>();
|
||||
const [need_check_into_view, set_need_check_into_view] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const current_category = useMemo(
|
||||
() =>
|
||||
(Object.entries(menuItemsMap).find(
|
||||
([, infos]) =>
|
||||
infos.findIndex(info => current_item === info.type) !== -1
|
||||
)?.[0] || CommandMenuCategories.pages) as CommandMenuCategories,
|
||||
[current_item]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (need_check_into_view) {
|
||||
if (current_item && menu_ref.current) {
|
||||
const item_ele =
|
||||
menu_ref.current.querySelector<HTMLButtonElement>(
|
||||
`.item-${current_item}`
|
||||
);
|
||||
const scroll_ele =
|
||||
menu_ref.current.querySelector<HTMLButtonElement>(
|
||||
`.${commonListContainer}`
|
||||
);
|
||||
if (item_ele) {
|
||||
const itemRect = domToRect(item_ele);
|
||||
const scrollRect = domToRect(scroll_ele);
|
||||
if (
|
||||
itemRect.top < scrollRect.top ||
|
||||
itemRect.bottom > scrollRect.bottom
|
||||
) {
|
||||
// IMP: may be do it with self function
|
||||
item_ele.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
set_need_check_into_view(false);
|
||||
}
|
||||
}, [need_check_into_view, current_item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types) {
|
||||
set_current_item(types[0]);
|
||||
}
|
||||
if (!isShow) {
|
||||
onclose && onclose();
|
||||
}
|
||||
}, [isShow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types) {
|
||||
if (!types.includes(current_item)) {
|
||||
set_need_check_into_view(true);
|
||||
if (types.length) {
|
||||
set_current_item(types[0]);
|
||||
} else {
|
||||
set_current_item(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isShow, types, current_item]);
|
||||
|
||||
const handle_click_up = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!current_item && types.length) {
|
||||
set_current_item(types[types.length - 1]);
|
||||
}
|
||||
if (current_item) {
|
||||
const idx = types.indexOf(current_item);
|
||||
if (idx > 0) {
|
||||
set_need_check_into_view(true);
|
||||
set_current_item(types[idx - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, current_item]
|
||||
);
|
||||
|
||||
const handle_click_down = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (!current_item && types.length) {
|
||||
set_current_item(types[0]);
|
||||
}
|
||||
if (current_item) {
|
||||
const idx = types.indexOf(current_item);
|
||||
if (idx < types.length - 1) {
|
||||
set_need_check_into_view(true);
|
||||
set_current_item(types[idx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, current_item]
|
||||
);
|
||||
|
||||
const handle_click_enter = useCallback(
|
||||
async (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && event.code === 'Enter' && current_item) {
|
||||
event.preventDefault();
|
||||
onSelected && onSelected(current_item);
|
||||
}
|
||||
},
|
||||
[isShow, current_item, onSelected]
|
||||
);
|
||||
|
||||
const handle_key_down = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
handle_click_up(event);
|
||||
handle_click_down(event);
|
||||
handle_click_enter(event);
|
||||
},
|
||||
[handle_click_up, handle_click_down, handle_click_enter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hooks.addHook(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE, handle_key_down);
|
||||
|
||||
return () => {
|
||||
hooks.removeHook(
|
||||
HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE,
|
||||
handle_key_down
|
||||
);
|
||||
};
|
||||
}, [hooks, handle_key_down]);
|
||||
|
||||
const handleSetCategories = (type: CommandMenuCategories) => {
|
||||
const newItem = menuItemsMap[type][0].type;
|
||||
set_need_check_into_view(true);
|
||||
set_current_item(newItem);
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
const blocks = searchBlocks?.map(
|
||||
block => ({ block } as CommonListItem)
|
||||
);
|
||||
if (blocks?.length) {
|
||||
blocks.push({ divider: CommandMenuCategories.pages });
|
||||
}
|
||||
return [
|
||||
...(blocks || []),
|
||||
...Object.entries(menuItemsMap).flatMap(
|
||||
([category, items], idx, all) => {
|
||||
let render_separator = false;
|
||||
const lines: CommonListItem[] = items
|
||||
.filter(item => types.includes(item.type))
|
||||
.map(item => {
|
||||
const { text, type, icon } = item;
|
||||
render_separator = true;
|
||||
return {
|
||||
content: { id: type, content: text, icon },
|
||||
};
|
||||
});
|
||||
if (render_separator && idx !== all.length - 1) {
|
||||
lines.push({ divider: category });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
),
|
||||
];
|
||||
}, [searchBlocks, types]);
|
||||
|
||||
return isShow ? (
|
||||
<div
|
||||
ref={menu_ref}
|
||||
className={styles('rootContainer')}
|
||||
onKeyDownCapture={handle_key_down}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles('contentContainer')}>
|
||||
<MenuCategories
|
||||
currentCategories={current_category}
|
||||
onSetCategories={handleSetCategories}
|
||||
categories={categories}
|
||||
/>
|
||||
<CommonList
|
||||
items={items}
|
||||
onSelected={type => onSelected?.(type)}
|
||||
currentItem={current_item}
|
||||
setCurrentItem={set_current_item}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const styles = style9.create({
|
||||
rootContainer: {
|
||||
position: 'fixed',
|
||||
zIndex: 1,
|
||||
width: 352,
|
||||
maxHeight: 525,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0px 1px 10px rgba(152, 172, 189, 0.6)',
|
||||
backgroundColor: '#fff',
|
||||
padding: '8px 4px',
|
||||
},
|
||||
contentContainer: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 493,
|
||||
},
|
||||
});
|
||||
265
libs/components/editor-plugins/src/menu/command-menu/Menu.tsx
Normal file
265
libs/components/editor-plugins/src/menu/command-menu/Menu.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
202
libs/components/editor-plugins/src/menu/command-menu/config.ts
Normal file
202
libs/components/editor-plugins/src/menu/command-menu/config.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CommandMenu';
|
||||
@@ -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%',
|
||||
});
|
||||
189
libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx
Normal file
189
libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
libs/components/editor-plugins/src/menu/group-menu/Line.tsx
Normal file
63
libs/components/editor-plugins/src/menu/group-menu/Line.tsx
Normal 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,
|
||||
});
|
||||
98
libs/components/editor-plugins/src/menu/group-menu/Menu.tsx
Normal file
98
libs/components/editor-plugins/src/menu/group-menu/Menu.tsx
Normal 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',
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Plugin';
|
||||
9
libs/components/editor-plugins/src/menu/index.ts
Normal file
9
libs/components/editor-plugins/src/menu/index.ts
Normal 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';
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
102
libs/components/editor-plugins/src/menu/inline-menu/config.ts
Normal file
102
libs/components/editor-plugins/src/menu/inline-menu/config.ts
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
export { InlineMenuPlugin } from './Plugin';
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MenuIconItem } from './IconItem';
|
||||
export { MenuDropdownItem } from './DropdownItem';
|
||||
50
libs/components/editor-plugins/src/menu/inline-menu/types.ts
Normal file
50
libs/components/editor-plugins/src/menu/inline-menu/types.ts
Normal 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;
|
||||
833
libs/components/editor-plugins/src/menu/inline-menu/utils.ts
Normal file
833
libs/components/editor-plugins/src/menu/inline-menu/utils.ts
Normal 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));
|
||||
};
|
||||
101
libs/components/editor-plugins/src/menu/left-menu/LeftMenu.tsx
Normal file
101
libs/components/editor-plugins/src/menu/left-menu/LeftMenu.tsx
Normal 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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
104
libs/components/editor-plugins/src/menu/left-menu/menu-config.ts
Normal file
104
libs/components/editor-plugins/src/menu/left-menu/menu-config.ts
Normal 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,
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SelectionGroupPlugin } from './SelectionGroupPlugin';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
libs/components/editor-plugins/src/placeholder/index.ts
Normal file
1
libs/components/editor-plugins/src/placeholder/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Placeholder';
|
||||
50
libs/components/editor-plugins/src/search/index.tsx
Normal file
50
libs/components/editor-plugins/src/search/index.tsx
Normal 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';
|
||||
117
libs/components/editor-plugins/src/search/search.tsx
Normal file
117
libs/components/editor-plugins/src/search/search.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
libs/components/editor-plugins/src/template/index.ts
Normal file
1
libs/components/editor-plugins/src/template/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './template';
|
||||
6
libs/components/editor-plugins/src/template/readme.md
Normal file
6
libs/components/editor-plugins/src/template/readme.md
Normal file
@@ -0,0 +1,6 @@
|
||||
ExportJson
|
||||
|
||||
```
|
||||
virgo.plugins.plugins.tempalte.exportTemplateJson()
|
||||
.then(data => console.log(JSON.stringify(data)));
|
||||
```
|
||||
107
libs/components/editor-plugins/src/template/template.ts
Normal file
107
libs/components/editor-plugins/src/template/template.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
libs/components/editor-plugins/src/utils/index.ts
Normal file
1
libs/components/editor-plugins/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PluginRenderRoot } from './plugin-render-root';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
26
libs/components/editor-plugins/tsconfig.json
Normal file
26
libs/components/editor-plugins/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
libs/components/editor-plugins/tsconfig.lib.json
Normal file
22
libs/components/editor-plugins/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
19
libs/components/editor-plugins/tsconfig.spec.json
Normal file
19
libs/components/editor-plugins/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user