mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: fixed editor bugs (#4552)
* fix: fixed editor bugs * fix: add error boundary
This commit is contained in:
parent
5b030303a6
commit
9746852b5f
@ -28,7 +28,6 @@
|
||||
"@mui/x-date-pickers-pro": "^6.18.2",
|
||||
"@reduxjs/toolkit": "2.0.0",
|
||||
"@slate-yjs/core": "^1.0.2",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@types/react-swipeable-views": "^0.13.4",
|
||||
"dayjs": "^1.11.9",
|
||||
@ -69,6 +68,7 @@
|
||||
"react18-input-otp": "^1.1.2",
|
||||
"redux": "^4.2.1",
|
||||
"rxjs": "^7.8.0",
|
||||
"sass": "^1.70.0",
|
||||
"slate": "^0.101.4",
|
||||
"slate-history": "^0.100.0",
|
||||
"slate-react": "^0.101.3",
|
||||
|
@ -31,9 +31,6 @@ dependencies:
|
||||
'@slate-yjs/core':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(slate@0.101.4)(yjs@13.6.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.0.0-beta.54
|
||||
version: 3.0.0-beta.54(react@18.2.0)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^1.2.0
|
||||
version: 1.3.0
|
||||
@ -154,6 +151,9 @@ dependencies:
|
||||
rxjs:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.1
|
||||
sass:
|
||||
specifier: ^1.70.0
|
||||
version: 1.70.0
|
||||
slate:
|
||||
specifier: ^0.101.4
|
||||
version: 0.101.4
|
||||
@ -296,7 +296,7 @@ devDependencies:
|
||||
version: 9.0.0
|
||||
vite:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.5(@types/node@18.16.9)
|
||||
version: 4.3.5(@types/node@18.16.9)(sass@1.70.0)
|
||||
vite-plugin-svgr:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(vite@4.3.5)
|
||||
@ -2061,19 +2061,6 @@ packages:
|
||||
svgo: 3.0.2
|
||||
dev: true
|
||||
|
||||
/@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0):
|
||||
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.0.0-beta.54
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@tanstack/virtual-core@3.0.0-beta.54:
|
||||
resolution: {integrity: sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==}
|
||||
dev: false
|
||||
|
||||
/@tauri-apps/api@1.3.0:
|
||||
resolution: {integrity: sha512-AH+3FonkKZNtfRtGrObY38PrzEj4d+1emCbwNGu0V2ENbXjlLHMZQlUh+Bhu/CRmjaIwZMGJ3yFvWaZZgTHoog==}
|
||||
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
|
||||
@ -2630,7 +2617,7 @@ packages:
|
||||
'@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.21.8)
|
||||
magic-string: 0.27.0
|
||||
react-refresh: 0.14.0
|
||||
vite: 4.3.5(@types/node@18.16.9)
|
||||
vite: 4.3.5(@types/node@18.16.9)(sass@1.70.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
@ -2887,7 +2874,6 @@ packages:
|
||||
/binary-extensions@2.2.0:
|
||||
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
@ -3020,7 +3006,6 @@ packages:
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
||||
/ci-info@3.8.0:
|
||||
resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==}
|
||||
@ -3930,7 +3915,6 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
dev: true
|
||||
|
||||
/glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
@ -4144,6 +4128,9 @@ packages:
|
||||
resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==}
|
||||
dev: false
|
||||
|
||||
/immutable@4.3.4:
|
||||
resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==}
|
||||
|
||||
/import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
@ -4217,7 +4204,6 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
binary-extensions: 2.2.0
|
||||
dev: true
|
||||
|
||||
/is-boolean-object@1.1.2:
|
||||
resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==}
|
||||
@ -4246,7 +4232,6 @@ packages:
|
||||
/is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
@ -4261,7 +4246,6 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
dev: true
|
||||
|
||||
/is-hotkey@0.2.0:
|
||||
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
|
||||
@ -6116,7 +6100,6 @@ packages:
|
||||
engines: {node: '>=8.10.0'}
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
dev: true
|
||||
|
||||
/redux-thunk@3.1.0(redux@5.0.0):
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
@ -6253,6 +6236,15 @@ packages:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
dev: true
|
||||
|
||||
/sass@1.70.0:
|
||||
resolution: {integrity: sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
chokidar: 3.5.3
|
||||
immutable: 4.3.4
|
||||
source-map-js: 1.0.2
|
||||
|
||||
/saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@ -6378,7 +6370,6 @@ packages:
|
||||
/source-map-js@1.0.2:
|
||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/source-map-support@0.5.13:
|
||||
resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==}
|
||||
@ -6965,13 +6956,13 @@ packages:
|
||||
'@rollup/pluginutils': 5.0.2
|
||||
'@svgr/core': 7.0.0
|
||||
'@svgr/plugin-jsx': 7.0.0
|
||||
vite: 4.3.5(@types/node@18.16.9)
|
||||
vite: 4.3.5(@types/node@18.16.9)(sass@1.70.0)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/vite@4.3.5(@types/node@18.16.9):
|
||||
/vite@4.3.5(@types/node@18.16.9)(sass@1.70.0):
|
||||
resolution: {integrity: sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
@ -7000,6 +6991,7 @@ packages:
|
||||
esbuild: 0.17.19
|
||||
postcss: 8.4.23
|
||||
rollup: 3.21.7
|
||||
sass: 1.70.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
@ -21,8 +21,10 @@ import get from 'lodash-es/get';
|
||||
import { EditorData, EditorNodeType } from '$app/application/document/document.types';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { Op } from 'quill-delta';
|
||||
import { Element } from 'slate';
|
||||
import { getInlinesWithDelta } from '$app/components/editor/provider/utils/convert';
|
||||
import { Element, Text } from 'slate';
|
||||
import { generateId, getInlinesWithDelta } from '$app/components/editor/provider/utils/convert';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { LIST_TYPES } from '$app/components/editor/command/tab';
|
||||
|
||||
export function blockPB2Node(block: BlockPB) {
|
||||
let data = {};
|
||||
@ -237,24 +239,38 @@ function flattenBlockJson(block: BlockJSON) {
|
||||
type: block.type,
|
||||
data: data,
|
||||
children: [],
|
||||
blockId: generateId(),
|
||||
};
|
||||
const isEmbed = CustomEditor.isEmbedNode(slateNode);
|
||||
|
||||
const textNode: Element | null = delta
|
||||
const textNode: {
|
||||
type: EditorNodeType.Text;
|
||||
children: (Text | Element)[];
|
||||
textId: string;
|
||||
} | null = !isEmbed
|
||||
? {
|
||||
type: 'text',
|
||||
children: [],
|
||||
type: EditorNodeType.Text,
|
||||
children: [{ text: '' }],
|
||||
textId: generateId(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const inlinesNodes = getInlinesWithDelta(delta);
|
||||
if (delta && textNode) {
|
||||
textNode.children = getInlinesWithDelta(delta);
|
||||
}
|
||||
|
||||
textNode?.children.push(...inlinesNodes);
|
||||
slateNode.children = block.children.map((child) => traverse(child));
|
||||
|
||||
const children = block.children;
|
||||
|
||||
slateNode.children = children.map((child) => traverse(child));
|
||||
if (textNode) {
|
||||
slateNode.children.unshift(textNode);
|
||||
if (!LIST_TYPES.includes(block.type as EditorNodeType) && slateNode.type !== EditorNodeType.Page) {
|
||||
slateNode.children.unshift(textNode);
|
||||
} else {
|
||||
slateNode.children.unshift({
|
||||
type: EditorNodeType.Paragraph,
|
||||
children: [textNode],
|
||||
blockId: generateId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slateNode;
|
||||
|
@ -34,6 +34,7 @@ export type BlockData = {
|
||||
};
|
||||
|
||||
export interface HeadingNode extends Element {
|
||||
blockId: string;
|
||||
type: EditorNodeType.HeadingBlock;
|
||||
data: {
|
||||
level: number;
|
||||
@ -41,6 +42,7 @@ export interface HeadingNode extends Element {
|
||||
}
|
||||
|
||||
export interface GridNode extends Element {
|
||||
blockId: string;
|
||||
type: EditorNodeType.GridBlock;
|
||||
data: {
|
||||
viewId?: string;
|
||||
@ -48,6 +50,7 @@ export interface GridNode extends Element {
|
||||
}
|
||||
|
||||
export interface TodoListNode extends Element {
|
||||
blockId: string;
|
||||
type: EditorNodeType.TodoListBlock;
|
||||
data: {
|
||||
checked: boolean;
|
||||
@ -55,6 +58,7 @@ export interface TodoListNode extends Element {
|
||||
}
|
||||
|
||||
export interface CodeNode extends Element {
|
||||
blockId: string;
|
||||
type: EditorNodeType.CodeBlock;
|
||||
data: {
|
||||
language: string;
|
||||
@ -62,19 +66,23 @@ export interface CodeNode extends Element {
|
||||
}
|
||||
|
||||
export interface QuoteNode extends Element {
|
||||
blockId: string;
|
||||
type: EditorNodeType.QuoteBlock;
|
||||
}
|
||||
|
||||
export interface NumberedListNode extends Element {
|
||||
type: EditorNodeType.NumberedListBlock;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface BulletedListNode extends Element {
|
||||
type: EditorNodeType.BulletedListBlock;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface ToggleListNode extends Element {
|
||||
type: EditorNodeType.ToggleListBlock;
|
||||
blockId: string;
|
||||
data: {
|
||||
collapsed: boolean;
|
||||
} & BlockData;
|
||||
@ -82,10 +90,12 @@ export interface ToggleListNode extends Element {
|
||||
|
||||
export interface DividerNode extends Element {
|
||||
type: EditorNodeType.DividerBlock;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface CalloutNode extends Element {
|
||||
type: EditorNodeType.CalloutBlock;
|
||||
blockId: string;
|
||||
data: {
|
||||
icon: string;
|
||||
} & BlockData;
|
||||
@ -93,6 +103,7 @@ export interface CalloutNode extends Element {
|
||||
|
||||
export interface MathEquationNode extends Element {
|
||||
type: EditorNodeType.EquationBlock;
|
||||
blockId: string;
|
||||
data: {
|
||||
formula?: string;
|
||||
} & BlockData;
|
||||
@ -182,6 +193,7 @@ export enum EditorMarkFormat {
|
||||
Href = 'href',
|
||||
FontColor = 'font_color',
|
||||
BgColor = 'bg_color',
|
||||
Align = 'align',
|
||||
}
|
||||
|
||||
export enum MentionType {
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import emojiData, { EmojiMartData } from '@emoji-mart/data';
|
||||
import { init, FrequentlyUsed, getEmojiDataFromNative, Store } from 'emoji-mart';
|
||||
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import chunk from 'lodash-es/chunk';
|
||||
|
||||
export const EMOJI_SIZE = 32;
|
||||
@ -164,16 +163,3 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize:
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useVirtualizedCategories({ count }: { count: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const virtualize = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => ref.current,
|
||||
estimateSize: () => {
|
||||
return EMOJI_SIZE;
|
||||
},
|
||||
});
|
||||
|
||||
return { virtualize, ref };
|
||||
}
|
||||
|
@ -6,13 +6,14 @@ import EmojiPickerCategories from './EmojiPickerCategories';
|
||||
|
||||
interface Props {
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
function EmojiPicker(props: Props) {
|
||||
function EmojiPicker({ onEscape, ...props }: Props) {
|
||||
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
|
||||
|
||||
return (
|
||||
<div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
|
||||
<div tabIndex={0} className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
|
||||
<EmojiPickerHeader
|
||||
onEmojiSelect={onSelect}
|
||||
skin={skin}
|
||||
@ -20,7 +21,7 @@ function EmojiPicker(props: Props) {
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
/>
|
||||
<EmojiPickerCategories onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
|
||||
<EmojiPickerCategories onEscape={onEscape} onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,30 +1,35 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
EMOJI_SIZE,
|
||||
EmojiCategory,
|
||||
getRowsWithCategories,
|
||||
PER_ROW_EMOJI_COUNT,
|
||||
useVirtualizedCategories,
|
||||
} from '$app/components/_shared/emoji_picker/EmojiPicker.hooks';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconButton } from '@mui/material';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { getDistanceEdge, inView } from '$app/components/_shared/keyboard_navigation/utils';
|
||||
|
||||
function EmojiPickerCategories({
|
||||
emojiCategories,
|
||||
onEmojiSelect,
|
||||
onEscape,
|
||||
}: {
|
||||
emojiCategories: EmojiCategory[];
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onEscape?: () => void;
|
||||
}) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const [selectCell, setSelectCell] = React.useState({
|
||||
row: 1,
|
||||
column: 0,
|
||||
});
|
||||
const rows = useMemo(() => {
|
||||
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
|
||||
}, [emojiCategories]);
|
||||
|
||||
const { ref, virtualize } = useVirtualizedCategories({
|
||||
count: rows.length,
|
||||
});
|
||||
const virtualItems = virtualize.getVirtualItems();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const getCategoryName = useCallback(
|
||||
(id: string) => {
|
||||
@ -45,67 +50,282 @@ function EmojiPickerCategories({
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
|
||||
setSelectCell({
|
||||
row: 1,
|
||||
column: 0,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = rows[index];
|
||||
|
||||
return (
|
||||
<div style={style} data-index={index}>
|
||||
{item.type === 'category' ? (
|
||||
<div className={'pt-2 text-xs font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
|
||||
) : null}
|
||||
<div className={'flex'}>
|
||||
{item.emojis?.map((emoji, columnIndex) => {
|
||||
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emoji.id}
|
||||
data-key={emoji.id}
|
||||
style={{
|
||||
width: EMOJI_SIZE,
|
||||
height: EMOJI_SIZE,
|
||||
}}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji.native);
|
||||
}}
|
||||
className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-active ${
|
||||
isSelected ? 'bg-fill-list-hover' : ''
|
||||
}`}
|
||||
>
|
||||
{emoji.native}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
|
||||
);
|
||||
|
||||
const getNewColumnIndex = useCallback(
|
||||
(rowIndex: number, columnIndex: number): number => {
|
||||
const row = rows[rowIndex];
|
||||
const length = row.emojis?.length;
|
||||
let newColumnIndex = columnIndex;
|
||||
|
||||
if (length && length <= columnIndex) {
|
||||
newColumnIndex = length - 1 || 0;
|
||||
}
|
||||
|
||||
return newColumnIndex;
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const findNextRow = useCallback(
|
||||
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
|
||||
const rowLength = rows.length;
|
||||
let nextRowIndex = rowIndex + 1;
|
||||
|
||||
if (nextRowIndex >= rowLength - 1) {
|
||||
nextRowIndex = rowLength - 1;
|
||||
} else if (rows[nextRowIndex].type === 'category') {
|
||||
nextRowIndex = findNextRow(nextRowIndex, columnIndex).row;
|
||||
}
|
||||
|
||||
const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex);
|
||||
|
||||
return {
|
||||
row: nextRowIndex,
|
||||
column: newColumnIndex,
|
||||
};
|
||||
},
|
||||
[getNewColumnIndex, rows]
|
||||
);
|
||||
|
||||
const findPrevRow = useCallback(
|
||||
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
|
||||
let prevRowIndex = rowIndex - 1;
|
||||
|
||||
if (prevRowIndex < 1) {
|
||||
prevRowIndex = 1;
|
||||
} else if (rows[prevRowIndex].type === 'category') {
|
||||
prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row;
|
||||
}
|
||||
|
||||
const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex);
|
||||
|
||||
return {
|
||||
row: prevRowIndex,
|
||||
column: newColumnIndex,
|
||||
};
|
||||
},
|
||||
[getNewColumnIndex, rows]
|
||||
);
|
||||
|
||||
const findPrevCell = useCallback(
|
||||
(row: number, column: number): { row: number; column: number } => {
|
||||
const prevColumn = column - 1;
|
||||
|
||||
if (prevColumn < 0) {
|
||||
const prevRow = findPrevRow(row, column).row;
|
||||
|
||||
if (prevRow === row) return { row, column };
|
||||
const length = rows[prevRow].emojis?.length || 0;
|
||||
|
||||
return {
|
||||
row: prevRow,
|
||||
column: length > 0 ? length - 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
row,
|
||||
column: prevColumn,
|
||||
};
|
||||
},
|
||||
[findPrevRow, rows]
|
||||
);
|
||||
|
||||
const findNextCell = useCallback(
|
||||
(row: number, column: number): { row: number; column: number } => {
|
||||
const nextColumn = column + 1;
|
||||
|
||||
const rowLength = rows[row].emojis?.length || 0;
|
||||
|
||||
if (nextColumn >= rowLength) {
|
||||
const nextRow = findNextRow(row, column).row;
|
||||
|
||||
if (nextRow === row) return { row, column };
|
||||
return {
|
||||
row: nextRow,
|
||||
column: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
row,
|
||||
column: nextColumn,
|
||||
};
|
||||
},
|
||||
[findNextRow, rows]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectCell || !scrollRef.current) return;
|
||||
const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id;
|
||||
const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`);
|
||||
|
||||
if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) {
|
||||
const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement);
|
||||
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current?.scrollTop + distance,
|
||||
});
|
||||
}
|
||||
}, [selectCell, rows]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onEscape?.();
|
||||
break;
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
|
||||
setSelectCell(findPrevRow(selectCell.row, selectCell.column));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
|
||||
setSelectCell(findNextRow(selectCell.row, selectCell.column));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
|
||||
const prevCell = findPrevCell(selectCell.row, selectCell.column);
|
||||
|
||||
setSelectCell(prevCell);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault();
|
||||
|
||||
const nextCell = findNextCell(selectCell.row, selectCell.column);
|
||||
|
||||
setSelectCell(nextCell);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
const currentRow = rows[selectCell.row];
|
||||
const emoji = currentRow.emojis?.[selectCell.column];
|
||||
|
||||
if (emoji) {
|
||||
onEmojiSelect(emoji.native);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
findNextCell,
|
||||
findPrevCell,
|
||||
findPrevRow,
|
||||
findNextRow,
|
||||
onEmojiSelect,
|
||||
onEscape,
|
||||
rows,
|
||||
selectCell.column,
|
||||
selectCell.row,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement;
|
||||
|
||||
const parentElement = ref.current?.parentElement;
|
||||
|
||||
focusElement?.addEventListener('keydown', handleKeyDown);
|
||||
parentElement?.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
focusElement?.removeEventListener('keydown', handleKeyDown);
|
||||
parentElement?.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`mt-2 w-[${
|
||||
EMOJI_SIZE * PER_ROW_EMOJI_COUNT
|
||||
}px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden`}
|
||||
}px] flex-1 transform items-center justify-center overflow-y-auto overflow-x-hidden`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualize.getTotalSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
className={'mx-1'}
|
||||
>
|
||||
{virtualItems.length ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0].start || 0}px)`,
|
||||
}}
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<FixedSizeList
|
||||
overscanCount={10}
|
||||
height={height}
|
||||
width={width}
|
||||
outerRef={scrollRef}
|
||||
itemCount={rows.length}
|
||||
itemSize={EMOJI_SIZE}
|
||||
itemData={rows}
|
||||
>
|
||||
{virtualItems.map(({ index }) => {
|
||||
const item = rows[index];
|
||||
|
||||
return (
|
||||
<div data-index={index} ref={virtualize.measureElement} key={item.id} className={'flex flex-col'}>
|
||||
{item.type === 'category' ? (
|
||||
<div className={'p-2 text-sm font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
|
||||
) : null}
|
||||
<div className={'flex'}>
|
||||
{item.emojis?.map((emoji) => {
|
||||
return (
|
||||
<div
|
||||
key={emoji.id}
|
||||
style={{
|
||||
width: EMOJI_SIZE,
|
||||
height: EMOJI_SIZE,
|
||||
}}
|
||||
className={`flex items-center justify-center`}
|
||||
>
|
||||
<IconButton
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji.native);
|
||||
}}
|
||||
>
|
||||
{emoji.native}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{renderRow}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={'h-[45px] px-0.5'}>
|
||||
<div className={'px-0.5 py-2'}>
|
||||
<div className={'search-input flex items-end'}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', marginRight: 2 }}>
|
||||
<SearchOutlined sx={{ color: 'action.active', mr: 1, my: 0.5 }} />
|
||||
@ -59,7 +59,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
|
||||
onSearchChange(e.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
label={t('search.label')}
|
||||
autoCorrect={'off'}
|
||||
autoComplete={'off'}
|
||||
spellCheck={false}
|
||||
className={'search-emoji-input'}
|
||||
placeholder={t('search.label')}
|
||||
variant='standard'
|
||||
/>
|
||||
</Box>
|
||||
|
@ -0,0 +1,16 @@
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { Alert } from '@mui/material';
|
||||
|
||||
export default function withErrorBoundary<T extends object>(WrappedComponent: React.ComponentType<T>) {
|
||||
return (props: T) => (
|
||||
<ErrorBoundary
|
||||
onError={(e) => {
|
||||
Log.error(e);
|
||||
}}
|
||||
fallback={<Alert color={'error'}>Something went wrong</Alert>}
|
||||
>
|
||||
<WrappedComponent {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@ -4,7 +4,21 @@ import { BlockMath, InlineMath } from 'react-katex';
|
||||
import './index.css';
|
||||
|
||||
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
|
||||
return isInline ? <InlineMath math={latex} /> : <BlockMath math={latex} />;
|
||||
return isInline ? (
|
||||
<InlineMath math={latex} />
|
||||
) : (
|
||||
<BlockMath
|
||||
renderError={(error) => {
|
||||
return (
|
||||
<div className='flex h-[51px] items-center justify-center'>
|
||||
{error.name}: {error.message}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{latex}
|
||||
</BlockMath>
|
||||
);
|
||||
}
|
||||
|
||||
export default KatexMath;
|
||||
|
@ -0,0 +1,303 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MenuItem, Typography } from '@mui/material';
|
||||
import { scrollIntoView } from '$app/components/_shared/keyboard_navigation/utils';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The option of the keyboard navigation
|
||||
* the options will be flattened
|
||||
* - key: the key of the option
|
||||
* - content: the content of the option
|
||||
* - children: the children of the option
|
||||
*/
|
||||
export interface KeyboardNavigationOption<T = string> {
|
||||
key: T;
|
||||
content?: React.ReactNode;
|
||||
children?: KeyboardNavigationOption<T>[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* - scrollRef: the scrollable element
|
||||
* - focusRef: the element to focus when the keyboard navigation is disabled
|
||||
* - options: the options to navigate
|
||||
* - onSelected: called when an option is selected(hovered)
|
||||
* - onConfirm: called when an option is confirmed
|
||||
* - onEscape: called when the escape key is pressed
|
||||
* - onPressRight: called when the right arrow is pressed
|
||||
* - onPressLeft: called when the left arrow key is pressed
|
||||
* - disableFocus: disable the focus on the keyboard navigation
|
||||
* - disableSelect: disable selecting an option when the options are initialized
|
||||
* - onKeyDown: called when a key is pressed
|
||||
* - defaultFocusedKey: the default focused key
|
||||
* - onFocus: called when the keyboard navigation is focused
|
||||
* - onBlur: called when the keyboard navigation is blurred
|
||||
*/
|
||||
export interface KeyboardNavigationProps<T> {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
focusRef?: React.RefObject<HTMLElement>;
|
||||
options: KeyboardNavigationOption<T>[];
|
||||
onSelected?: (optionKey: T) => void;
|
||||
onConfirm?: (optionKey: T) => void;
|
||||
onEscape?: () => void;
|
||||
onPressRight?: (optionKey: T) => void;
|
||||
onPressLeft?: (optionKey: T) => void;
|
||||
disableFocus?: boolean;
|
||||
disableSelect?: boolean;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
defaultFocusedKey?: T;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function KeyboardNavigation<T>({
|
||||
defaultFocusedKey,
|
||||
onPressRight,
|
||||
onPressLeft,
|
||||
onEscape,
|
||||
onConfirm,
|
||||
scrollRef,
|
||||
options,
|
||||
onSelected,
|
||||
focusRef,
|
||||
disableFocus = false,
|
||||
onKeyDown: onPropsKeyDown,
|
||||
disableSelect = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
}: KeyboardNavigationProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const mouseY = useRef<number | null>(null);
|
||||
const defaultKeyRef = useRef<T | undefined>(defaultFocusedKey);
|
||||
// flatten the options
|
||||
const flattenOptions = useMemo(() => {
|
||||
return options.flatMap((group) => {
|
||||
if (group.children) {
|
||||
return group.children;
|
||||
}
|
||||
|
||||
return [group];
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
const [focusedKey, setFocusedKey] = useState<T>();
|
||||
|
||||
const firstOptionKey = useMemo(() => {
|
||||
if (disableSelect) return;
|
||||
const firstOption = flattenOptions.find((option) => !option.disabled);
|
||||
|
||||
return firstOption?.key;
|
||||
}, [flattenOptions, disableSelect]);
|
||||
|
||||
// set the default focused key when the options are initialized
|
||||
useEffect(() => {
|
||||
if (defaultKeyRef.current) {
|
||||
setFocusedKey(defaultKeyRef.current);
|
||||
defaultKeyRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedKey(firstOptionKey);
|
||||
}, [firstOptionKey]);
|
||||
|
||||
// call the onSelected callback when the focused key is changed
|
||||
useEffect(() => {
|
||||
if (focusedKey === undefined) return;
|
||||
onSelected?.(focusedKey);
|
||||
|
||||
const scrollElement = scrollRef.current;
|
||||
|
||||
if (!scrollElement) return;
|
||||
|
||||
const dom = ref.current?.querySelector(`[data-key="${focusedKey}"]`);
|
||||
|
||||
if (!dom) return;
|
||||
// scroll the focused option into view
|
||||
requestAnimationFrame(() => {
|
||||
scrollIntoView(dom as HTMLDivElement, scrollElement);
|
||||
});
|
||||
}, [focusedKey, onSelected, scrollRef]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
onPropsKeyDown?.(e);
|
||||
e.stopPropagation();
|
||||
const key = e.key;
|
||||
|
||||
if (key === 'Tab') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onEscape?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedKey === undefined) return;
|
||||
const focusedIndex = flattenOptions.findIndex((option) => option?.key === focusedKey);
|
||||
const nextIndex = (focusedIndex + 1) % flattenOptions.length;
|
||||
const prevIndex = (focusedIndex - 1 + flattenOptions.length) % flattenOptions.length;
|
||||
|
||||
switch (key) {
|
||||
// move the focus to the previous option
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
|
||||
const prevKey = flattenOptions[prevIndex]?.key;
|
||||
|
||||
setFocusedKey(prevKey);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// move the focus to the next option
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
const nextKey = flattenOptions[nextIndex]?.key;
|
||||
|
||||
setFocusedKey(nextKey);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowRight':
|
||||
if (onPressRight) {
|
||||
e.preventDefault();
|
||||
onPressRight(focusedKey);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (onPressLeft) {
|
||||
e.preventDefault();
|
||||
onPressLeft(focusedKey);
|
||||
}
|
||||
|
||||
break;
|
||||
// confirm the focused option
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
const disabled = flattenOptions[focusedIndex]?.disabled;
|
||||
|
||||
if (!disabled) {
|
||||
onConfirm?.(focusedKey);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[flattenOptions, focusedKey, onConfirm, onEscape, onPressLeft, onPressRight, onPropsKeyDown]
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: KeyboardNavigationOption<T>, index: number) => {
|
||||
const hasChildren = option.children && option.children.length > 0;
|
||||
|
||||
const isFocused = focusedKey === option.key;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col gap-1'} key={option.key as string}>
|
||||
{hasChildren ? (
|
||||
// render the group name
|
||||
option.content && <div className={'text-text-caption'}>{option.content}</div>
|
||||
) : (
|
||||
// render the option
|
||||
<MenuItem
|
||||
disabled={option.disabled}
|
||||
data-key={option.key}
|
||||
// prevent the focused option is changed when the mouse is not moved but the mouse is entered when scrolling into view
|
||||
onMouseMove={(e) => {
|
||||
mouseY.current = e.clientY;
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (mouseY.current === null || mouseY.current !== e.clientY) {
|
||||
setFocusedKey(option.key);
|
||||
}
|
||||
|
||||
mouseY.current = e.clientY;
|
||||
}}
|
||||
onClick={() => {
|
||||
setFocusedKey(option.key);
|
||||
if (!option.disabled) {
|
||||
onConfirm?.(option.key);
|
||||
}
|
||||
}}
|
||||
selected={isFocused}
|
||||
className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${
|
||||
!isFocused ? 'hover:bg-transparent' : ''
|
||||
}`}
|
||||
>
|
||||
{option.content}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{option.children?.map((child, childIndex) => {
|
||||
return renderOption(child, index + childIndex);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[focusedKey, onConfirm]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
|
||||
if (!disableFocus && element) {
|
||||
element.focus();
|
||||
element.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
} else {
|
||||
let element: HTMLElement | null | undefined = focusRef?.current;
|
||||
|
||||
if (!element) {
|
||||
element = ReactEditor.toDOMNode(editor, editor);
|
||||
}
|
||||
|
||||
element.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
element?.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}
|
||||
}, [disableFocus, editor, onKeyDown, focusRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
onFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onFocus?.();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onBlur?.();
|
||||
}}
|
||||
autoFocus={!disableFocus}
|
||||
className={'flex w-full flex-col gap-1 outline-none'}
|
||||
ref={ref}
|
||||
>
|
||||
{options.length > 0 ? (
|
||||
options.map(renderOption)
|
||||
) : (
|
||||
<Typography variant='body1' className={'p-3 text-text-caption'}>
|
||||
{t('findAndReplace.noResult')}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyboardNavigation;
|
@ -0,0 +1,32 @@
|
||||
export function inView(dom: HTMLElement, container: HTMLElement) {
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (!domRect || !containerRect) return true;
|
||||
|
||||
return domRect?.bottom <= containerRect?.bottom && domRect?.top >= containerRect?.top;
|
||||
}
|
||||
|
||||
export function getDistanceEdge(dom: HTMLElement, container: HTMLElement) {
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (!domRect || !containerRect) return 0;
|
||||
|
||||
const distanceTop = domRect?.top - containerRect?.top;
|
||||
const distanceBottom = domRect?.bottom - containerRect?.bottom;
|
||||
|
||||
return Math.abs(distanceTop) < Math.abs(distanceBottom) ? distanceTop : distanceBottom;
|
||||
}
|
||||
|
||||
export function scrollIntoView(dom: HTMLElement, container: HTMLElement) {
|
||||
const isDomInView = inView(dom, container);
|
||||
|
||||
if (isDomInView) return;
|
||||
|
||||
dom.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
|
||||
interface PopoverPosition {
|
||||
anchorOrigin: PopoverOrigin;
|
||||
transformOrigin: PopoverOrigin;
|
||||
paperWidth: number;
|
||||
paperHeight: number;
|
||||
isEntered: boolean;
|
||||
anchorPosition?: { left: number; top: number };
|
||||
}
|
||||
|
||||
interface UsePopoverAutoPositionProps {
|
||||
anchorEl?: HTMLElement | null;
|
||||
anchorPosition?: { left: number; top: number; height: number };
|
||||
initialAnchorOrigin?: PopoverOrigin;
|
||||
initialTransformOrigin?: PopoverOrigin;
|
||||
initialPaperWidth: number;
|
||||
initialPaperHeight: number;
|
||||
marginThreshold?: number;
|
||||
open: boolean;
|
||||
anchorSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const minPaperWidth = 80;
|
||||
const minPaperHeight = 120;
|
||||
|
||||
function getOffsetLeft(
|
||||
rect: {
|
||||
height: number;
|
||||
width: number;
|
||||
},
|
||||
horizontal: number | 'center' | 'left' | 'right'
|
||||
) {
|
||||
let offset = 0;
|
||||
|
||||
if (typeof horizontal === 'number') {
|
||||
offset = horizontal;
|
||||
} else if (horizontal === 'center') {
|
||||
offset = rect.width / 2;
|
||||
} else if (horizontal === 'right') {
|
||||
offset = rect.width;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
function getOffsetTop(
|
||||
rect: {
|
||||
height: number;
|
||||
width: number;
|
||||
},
|
||||
vertical: number | 'center' | 'bottom' | 'top'
|
||||
) {
|
||||
let offset = 0;
|
||||
|
||||
if (typeof vertical === 'number') {
|
||||
offset = vertical;
|
||||
} else if (vertical === 'center') {
|
||||
offset = rect.height / 2;
|
||||
} else if (vertical === 'bottom') {
|
||||
offset = rect.height;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
const defaultAnchorOrigin: PopoverOrigin = {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
};
|
||||
|
||||
const defaultTransformOrigin: PopoverOrigin = {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
};
|
||||
|
||||
const usePopoverAutoPosition = ({
|
||||
anchorEl,
|
||||
anchorPosition,
|
||||
initialAnchorOrigin = defaultAnchorOrigin,
|
||||
initialTransformOrigin = defaultTransformOrigin,
|
||||
initialPaperWidth,
|
||||
initialPaperHeight,
|
||||
marginThreshold = 16,
|
||||
open,
|
||||
}: UsePopoverAutoPositionProps): PopoverPosition => {
|
||||
const [position, setPosition] = useState<PopoverPosition>({
|
||||
anchorOrigin: initialAnchorOrigin,
|
||||
transformOrigin: initialTransformOrigin,
|
||||
paperWidth: initialPaperWidth,
|
||||
paperHeight: initialPaperHeight,
|
||||
anchorPosition,
|
||||
isEntered: false,
|
||||
});
|
||||
|
||||
const getAnchorOffset = useCallback(() => {
|
||||
if (anchorPosition) {
|
||||
return {
|
||||
...anchorPosition,
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return anchorEl ? anchorEl.getBoundingClientRect() : undefined;
|
||||
}, [anchorEl, anchorPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const anchorRect = getAnchorOffset();
|
||||
|
||||
if (!anchorRect) return;
|
||||
let newPaperWidth = initialPaperWidth;
|
||||
let newPaperHeight = initialPaperHeight;
|
||||
const newAnchorPosition = {
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
};
|
||||
|
||||
// calculate new paper width
|
||||
const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal);
|
||||
const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical);
|
||||
|
||||
let isExceedViewportRight = false;
|
||||
let isExceedViewportBottom = false;
|
||||
let isExceedViewportLeft = false;
|
||||
let isExceedViewportTop = false;
|
||||
|
||||
// Check if exceed viewport right
|
||||
if (newLeft + newPaperWidth > viewportWidth - marginThreshold) {
|
||||
isExceedViewportRight = true;
|
||||
// Check if exceed viewport left
|
||||
if (newLeft - newPaperWidth < marginThreshold) {
|
||||
isExceedViewportLeft = true;
|
||||
newPaperWidth = Math.max(minPaperWidth, Math.min(newPaperWidth, viewportWidth - newLeft - marginThreshold));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if exceed viewport bottom
|
||||
if (newTop + newPaperHeight > viewportHeight - marginThreshold) {
|
||||
isExceedViewportBottom = true;
|
||||
// Check if exceed viewport top
|
||||
if (newTop - newPaperHeight < marginThreshold) {
|
||||
isExceedViewportTop = true;
|
||||
newPaperHeight = Math.max(minPaperHeight, Math.min(newPaperHeight, viewportHeight - newTop - marginThreshold));
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = {
|
||||
anchorOrigin: { ...initialAnchorOrigin },
|
||||
transformOrigin: { ...initialTransformOrigin },
|
||||
paperWidth: newPaperWidth,
|
||||
paperHeight: newPaperHeight,
|
||||
anchorPosition: newAnchorPosition,
|
||||
};
|
||||
|
||||
// If exceed viewport, adjust anchor origin and transform origin
|
||||
if (!isExceedViewportRight && !isExceedViewportLeft) {
|
||||
if (isExceedViewportBottom && !isExceedViewportTop) {
|
||||
newPosition.anchorOrigin.vertical = 'top';
|
||||
newPosition.transformOrigin.vertical = 'bottom';
|
||||
} else if (!isExceedViewportBottom && isExceedViewportTop) {
|
||||
newPosition.anchorOrigin.vertical = 'bottom';
|
||||
newPosition.transformOrigin.vertical = 'top';
|
||||
}
|
||||
} else if (!isExceedViewportBottom && !isExceedViewportTop) {
|
||||
if (isExceedViewportRight && !isExceedViewportLeft) {
|
||||
newPosition.anchorOrigin.horizontal = 'left';
|
||||
newPosition.transformOrigin.horizontal = 'right';
|
||||
} else if (!isExceedViewportRight && isExceedViewportLeft) {
|
||||
newPosition.anchorOrigin.horizontal = 'right';
|
||||
newPosition.transformOrigin.horizontal = 'left';
|
||||
}
|
||||
}
|
||||
|
||||
// anchorPosition is top-left of the anchor element, so we need to adjust it to avoid overlap with the anchor element
|
||||
if (newPosition.anchorOrigin.vertical === 'bottom' && newPosition.transformOrigin.vertical === 'top') {
|
||||
newPosition.anchorPosition.top += anchorRect.height;
|
||||
}
|
||||
|
||||
if (newPosition.anchorOrigin.vertical === 'top' && newPosition.transformOrigin.vertical === 'bottom') {
|
||||
newPosition.paperHeight = newPaperHeight - anchorRect.height;
|
||||
}
|
||||
|
||||
// Set new position and set isEntered to true
|
||||
setPosition({ ...newPosition, isEntered: true });
|
||||
}, [
|
||||
anchorPosition,
|
||||
open,
|
||||
initialAnchorOrigin,
|
||||
initialTransformOrigin,
|
||||
initialPaperWidth,
|
||||
initialPaperHeight,
|
||||
marginThreshold,
|
||||
getAnchorOffset,
|
||||
]);
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
export default usePopoverAutoPosition;
|
@ -0,0 +1,45 @@
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
|
||||
export function getOffsetTop(rect: DOMRect, vertical: number | 'center' | 'bottom' | 'top') {
|
||||
let offset = 0;
|
||||
|
||||
if (typeof vertical === 'number') {
|
||||
offset = vertical;
|
||||
} else if (vertical === 'center') {
|
||||
offset = rect.height / 2;
|
||||
} else if (vertical === 'bottom') {
|
||||
offset = rect.height;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function getOffsetLeft(rect: DOMRect, horizontal: number | 'center' | 'left' | 'right') {
|
||||
let offset = 0;
|
||||
|
||||
if (typeof horizontal === 'number') {
|
||||
offset = horizontal;
|
||||
} else if (horizontal === 'center') {
|
||||
offset = rect.width / 2;
|
||||
} else if (horizontal === 'right') {
|
||||
offset = rect.width;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function getAnchorOffset(anchorElement: HTMLElement, anchorOrigin: PopoverOrigin) {
|
||||
const anchorRect = anchorElement.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: anchorRect.top + getOffsetTop(anchorRect, anchorOrigin.vertical),
|
||||
left: anchorRect.left + getOffsetLeft(anchorRect, anchorOrigin.horizontal),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTransformOrigin(elemRect: DOMRect, transformOrigin: PopoverOrigin) {
|
||||
return {
|
||||
vertical: getOffsetTop(elemRect, transformOrigin.vertical),
|
||||
horizontal: getOffsetLeft(elemRect, transformOrigin.horizontal),
|
||||
};
|
||||
}
|
@ -22,7 +22,6 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
onUpdateIcon(emoji);
|
||||
setAnchorPosition(undefined);
|
||||
},
|
||||
[onUpdateIcon]
|
||||
);
|
||||
@ -38,13 +37,18 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
|
||||
{open && (
|
||||
<Popover
|
||||
open={open}
|
||||
autoFocus={true}
|
||||
disableRestoreFocus={false}
|
||||
anchorReference='anchorPosition'
|
||||
anchorPosition={anchorPosition}
|
||||
disableAutoFocus
|
||||
disableRestoreFocus
|
||||
onClose={() => setAnchorPosition(undefined)}
|
||||
>
|
||||
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||
<EmojiPicker
|
||||
onEscape={() => {
|
||||
setAnchorPosition(undefined);
|
||||
}}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
|
@ -183,3 +183,11 @@ export const useConnectDatabase = (viewId: string) => {
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
const DatabaseRenderedContext = createContext<() => void>(() => {
|
||||
// do nothing
|
||||
});
|
||||
|
||||
export const DatabaseRenderedProvider = DatabaseRenderedContext.Provider;
|
||||
|
||||
export const useDatabaseRendered = () => useContext(DatabaseRenderedContext);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useViewId } from '$app/hooks/ViewId.hooks';
|
||||
import { databaseViewService } from '$app/application/database';
|
||||
import { DatabaseTabBar } from './components';
|
||||
@ -19,8 +19,10 @@ interface Props {
|
||||
setSelectedViewId?: (viewId: string) => void;
|
||||
}
|
||||
|
||||
export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
export const Database = forwardRef<HTMLDivElement, Props>(({ selectedViewId, setSelectedViewId }, ref) => {
|
||||
const innerRef = useRef<HTMLDivElement>();
|
||||
const databaseRef = (ref ?? innerRef) as React.MutableRefObject<HTMLDivElement>;
|
||||
|
||||
const viewId = useViewId();
|
||||
const { t } = useTranslation();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
@ -28,26 +30,29 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
const [editRecordRowId, setEditRecordRowId] = useState<string | null>(null);
|
||||
const [openCollections, setOpenCollections] = useState<string[]>([]);
|
||||
|
||||
const handleResetDatabaseViews = useCallback(async (viewId: string) => {
|
||||
await databaseViewService
|
||||
.getDatabaseViews(viewId)
|
||||
.then((value) => {
|
||||
setChildViewIds(value.map((view) => view.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === ErrorCode.RecordNotFound) {
|
||||
setNotFound(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPageChanged = () => {
|
||||
void databaseViewService
|
||||
.getDatabaseViews(viewId)
|
||||
.then((value) => {
|
||||
setChildViewIds(value.map((view) => view.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === ErrorCode.RecordNotFound) {
|
||||
setNotFound(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onPageChanged();
|
||||
|
||||
void handleResetDatabaseViews(viewId);
|
||||
const unsubscribePromise = subscribeNotifications(
|
||||
{
|
||||
[FolderNotification.DidUpdateView]: () => {
|
||||
onPageChanged();
|
||||
[FolderNotification.DidUpdateChildViews]: (changeset) => {
|
||||
if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleResetDatabaseViews(viewId);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -56,7 +61,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
);
|
||||
|
||||
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
|
||||
}, [viewId]);
|
||||
}, [handleResetDatabaseViews, viewId]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
return Math.max(0, childViewIds.indexOf(selectedViewId ?? viewId));
|
||||
@ -100,7 +105,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className='appflowy-database relative flex flex-1 flex-col overflow-y-hidden'>
|
||||
<div ref={databaseRef} className='appflowy-database relative flex flex-1 flex-col overflow-y-hidden'>
|
||||
<DatabaseTabBar
|
||||
pageId={viewId}
|
||||
setSelectedViewId={setSelectedViewId}
|
||||
@ -120,7 +125,7 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
<DatabaseLoader viewId={id}>
|
||||
{selectedViewId === id && (
|
||||
<>
|
||||
<Portal container={ref.current}>
|
||||
<Portal container={databaseRef.current}>
|
||||
<div className={'absolute right-16 top-0 py-1'}>
|
||||
<DatabaseSettings
|
||||
onToggleCollection={(forceOpen?: boolean) => onToggleCollection(id, forceOpen)}
|
||||
@ -147,4 +152,4 @@ export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
|
||||
</SwipeableViews>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -2,10 +2,12 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
|
||||
import { deleteView, updateView } from '$app/application/database/database_view/database_view_service';
|
||||
import { deleteView } from '$app/application/database/database_view/database_view_service';
|
||||
import { MenuItem, MenuProps, Menu } from '@mui/material';
|
||||
import RenameDialog from '$app/components/layout/nested_page/RenameDialog';
|
||||
import { Page } from '$app_reducers/pages/slice';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { updatePageName } from '$app_reducers/pages/async_actions';
|
||||
|
||||
enum ViewAction {
|
||||
Rename,
|
||||
@ -15,6 +17,7 @@ enum ViewAction {
|
||||
function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewId = view.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const [openRenameDialog, setOpenRenameDialog] = useState(false);
|
||||
const options = [
|
||||
{
|
||||
@ -56,9 +59,12 @@ function ViewActions({ view, ...props }: { view: Page } & MenuProps) {
|
||||
onClose={() => setOpenRenameDialog(false)}
|
||||
onOk={async (val) => {
|
||||
try {
|
||||
await updateView(viewId, {
|
||||
name: val,
|
||||
});
|
||||
await dispatch(
|
||||
updatePageName({
|
||||
id: viewId,
|
||||
name: val,
|
||||
})
|
||||
);
|
||||
setOpenRenameDialog(false);
|
||||
props.onClose?.({}, 'backdropClick');
|
||||
} catch (e) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { FC, useCallback, useMemo, useRef } from 'react';
|
||||
import { RowMeta } from '$app/application/database';
|
||||
import { useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks';
|
||||
import { useDatabaseRendered, useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks';
|
||||
import { fieldsToColumns, GridColumn, RenderRow, RenderRowType, rowMetasToRenderRow } from '../constants';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window';
|
||||
@ -23,6 +23,7 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
||||
const ref = useRef<Grid<HTMLDivElement>>(null);
|
||||
const { columnWidth } = useGridColumn(columns, ref);
|
||||
const { rowHeight } = useGridRow();
|
||||
const onRendered = useDatabaseRendered();
|
||||
|
||||
const getItemKey = useCallback(
|
||||
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
|
||||
@ -114,7 +115,11 @@ export const GridTable: FC<GridTableProps> = React.memo(({ onEditRecord }) => {
|
||||
style={{
|
||||
overscrollBehavior: 'none',
|
||||
}}
|
||||
outerRef={scrollElementRef}
|
||||
className={'grid-scroll-container'}
|
||||
outerRef={(el) => {
|
||||
scrollElementRef.current = el;
|
||||
onRendered();
|
||||
}}
|
||||
innerRef={containerRef}
|
||||
>
|
||||
{Cell}
|
||||
|
@ -9,6 +9,7 @@ interface DocumentHeaderProps {
|
||||
|
||||
export function DocumentHeader({ page }: DocumentHeaderProps) {
|
||||
const pageId = page.id;
|
||||
|
||||
const onUpdateIcon = useCallback(
|
||||
async (icon: PageIcon) => {
|
||||
await updatePageIcon(pageId, icon.value ? icon : undefined);
|
||||
@ -18,7 +19,7 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
|
||||
|
||||
if (!page) return null;
|
||||
return (
|
||||
<div className={'document-header px-16 py-4'}>
|
||||
<div className={'document-header px-16 pt-4'}>
|
||||
<ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} />
|
||||
</div>
|
||||
);
|
||||
|
@ -4,6 +4,8 @@ import { EditorProps } from '../../application/document/document.types';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { CollaborativeEditor } from '$app/components/editor/components/editor';
|
||||
import { EditorIdProvider } from '$app/components/editor/Editor.hooks';
|
||||
import './editor.scss';
|
||||
import withErrorBoundary from '$app/components/_shared/error_boundary/withError';
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
return (
|
||||
@ -16,4 +18,4 @@ export function Editor(props: EditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Editor);
|
||||
export default withErrorBoundary(memo(Editor));
|
||||
|
@ -2,9 +2,9 @@ import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
|
||||
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
|
||||
|
||||
export function insertFormula(editor: ReactEditor) {
|
||||
export function insertFormula(editor: ReactEditor, formula?: string) {
|
||||
if (editor.selection) {
|
||||
wrapFormula(editor);
|
||||
wrapFormula(editor, formula);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,17 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Element, Node, NodeEntry, Point, Range, Transforms, Location } from 'slate';
|
||||
import {
|
||||
Editor,
|
||||
Element,
|
||||
Node,
|
||||
NodeEntry,
|
||||
Point,
|
||||
Range,
|
||||
Transforms,
|
||||
Location,
|
||||
Path,
|
||||
EditorBeforeOptions,
|
||||
Text,
|
||||
} from 'slate';
|
||||
import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab';
|
||||
import { isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark';
|
||||
import {
|
||||
@ -16,11 +28,19 @@ import {
|
||||
Mention,
|
||||
TodoListNode,
|
||||
ToggleListNode,
|
||||
inlineNodeTypes,
|
||||
FormulaNode,
|
||||
} from '$app/application/document/document.types';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
|
||||
export const EmbedTypes: string[] = [
|
||||
EditorNodeType.DividerBlock,
|
||||
EditorNodeType.EquationBlock,
|
||||
EditorNodeType.GridBlock,
|
||||
];
|
||||
|
||||
export const CustomEditor = {
|
||||
getBlock: (editor: ReactEditor, at?: Location): NodeEntry<Element> | undefined => {
|
||||
return Editor.above(editor, {
|
||||
@ -29,12 +49,48 @@ export const CustomEditor = {
|
||||
});
|
||||
},
|
||||
|
||||
isInlineNode: (editor: ReactEditor, point: Point): boolean => {
|
||||
return Boolean(
|
||||
editor.above({
|
||||
at: point,
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && inlineNodeTypes.includes(n.type as EditorInlineNodeType);
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
beforeIsInlineNode: (editor: ReactEditor, at: Location, opts?: EditorBeforeOptions): boolean => {
|
||||
const beforePoint = Editor.before(editor, at, opts);
|
||||
|
||||
if (!beforePoint) return false;
|
||||
return CustomEditor.isInlineNode(editor, beforePoint);
|
||||
},
|
||||
|
||||
afterIsInlineNode: (editor: ReactEditor, at: Location, opts?: EditorBeforeOptions): boolean => {
|
||||
const afterPoint = Editor.after(editor, at, opts);
|
||||
|
||||
if (!afterPoint) return false;
|
||||
return CustomEditor.isInlineNode(editor, afterPoint);
|
||||
},
|
||||
blockEqual: (editor: ReactEditor, point: Point, anotherPoint: Point) => {
|
||||
const match = CustomEditor.getBlock(editor, point);
|
||||
const anotherMatch = CustomEditor.getBlock(editor, anotherPoint);
|
||||
|
||||
if (!match || !anotherMatch) return false;
|
||||
|
||||
const [node] = match;
|
||||
const [anotherNode] = anotherMatch;
|
||||
|
||||
return node === anotherNode;
|
||||
},
|
||||
|
||||
/**
|
||||
* turn the current block to a new block
|
||||
* 1. clone the current block to a new block
|
||||
* 2. remove the current block
|
||||
* 3. insert the new block
|
||||
* 4. lift the children of the new block if the new block doesn't allow has children
|
||||
* 2. lift the children of the current block if the current block doesn't allow has children
|
||||
* 3. remove the old block
|
||||
* 4. insert the new block
|
||||
* @param editor
|
||||
* @param newProperties
|
||||
*/
|
||||
@ -50,20 +106,29 @@ export const CustomEditor = {
|
||||
|
||||
const cloneNode = CustomEditor.cloneBlock(editor, node);
|
||||
|
||||
Transforms.removeNodes(editor, {
|
||||
at: path,
|
||||
});
|
||||
|
||||
Object.assign(cloneNode, newProperties);
|
||||
|
||||
const [, ...children] = cloneNode.children;
|
||||
const isEmbed = editor.isEmbed(cloneNode);
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
if (isEmbed) {
|
||||
editor.splitNodes({
|
||||
always: true,
|
||||
});
|
||||
cloneNode.children = [];
|
||||
|
||||
Transforms.removeNodes(editor, {
|
||||
at: path,
|
||||
});
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
return;
|
||||
}
|
||||
|
||||
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
||||
|
||||
// if node doesn't allow has children, the children should be lifted
|
||||
// if node doesn't allow has children, lift the children before insert the new node and remove the old node
|
||||
if (!isListType) {
|
||||
const [textNode, ...children] = cloneNode.children;
|
||||
|
||||
const length = children.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
@ -71,14 +136,17 @@ export const CustomEditor = {
|
||||
at: [...path, length - i],
|
||||
});
|
||||
}
|
||||
|
||||
cloneNode.children = [textNode];
|
||||
}
|
||||
|
||||
const isSelectable = editor.isSelectable(cloneNode);
|
||||
Transforms.removeNodes(editor, {
|
||||
at: path,
|
||||
});
|
||||
|
||||
if (isSelectable) {
|
||||
Transforms.select(editor, selection);
|
||||
} else {
|
||||
Transforms.select(editor, path);
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
if (selection) {
|
||||
editor.select(selection);
|
||||
}
|
||||
},
|
||||
tabForward,
|
||||
@ -87,6 +155,7 @@ export const CustomEditor = {
|
||||
removeMarks,
|
||||
isMarkActive,
|
||||
isFormulaActive,
|
||||
insertFormula,
|
||||
updateFormula,
|
||||
deleteFormula,
|
||||
toggleFormula: (editor: ReactEditor) => {
|
||||
@ -110,14 +179,16 @@ export const CustomEditor = {
|
||||
toggleAlign(editor: ReactEditor, format: string) {
|
||||
const matchNodes = Array.from(
|
||||
Editor.nodes(editor, {
|
||||
match: (n) => Element.isElement(n) && n.blockId !== undefined,
|
||||
// Note: we need to select the text node instead of the element node, otherwise the parent node will be selected
|
||||
match: (n) => Element.isElement(n) && n.type === EditorNodeType.Text,
|
||||
})
|
||||
);
|
||||
|
||||
if (!matchNodes) return;
|
||||
|
||||
matchNodes.forEach((match) => {
|
||||
const [node] = match as NodeEntry<
|
||||
const [, textPath] = match as NodeEntry<Element>;
|
||||
const [node] = editor.parent(textPath) as NodeEntry<
|
||||
Element & {
|
||||
data: {
|
||||
align?: string;
|
||||
@ -148,6 +219,26 @@ export const CustomEditor = {
|
||||
return (node.data as { align?: string })?.align;
|
||||
},
|
||||
|
||||
isInlineActive(editor: ReactEditor) {
|
||||
const [match] = editor.nodes({
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && inlineNodeTypes.includes(n.type as EditorInlineNodeType);
|
||||
},
|
||||
});
|
||||
|
||||
return !!match;
|
||||
},
|
||||
|
||||
isMentionActive(editor: ReactEditor) {
|
||||
const [match] = editor.nodes({
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Mention;
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(match);
|
||||
},
|
||||
|
||||
insertMention(editor: ReactEditor, mention: Mention) {
|
||||
const mentionElement = {
|
||||
type: EditorInlineNodeType.Mention,
|
||||
@ -165,8 +256,10 @@ export const CustomEditor = {
|
||||
toggleTodo(editor: ReactEditor, node: TodoListNode) {
|
||||
const checked = node.data.checked;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
checked: !checked,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
@ -177,23 +270,39 @@ export const CustomEditor = {
|
||||
toggleToggleList(editor: ReactEditor, node: ToggleListNode) {
|
||||
const collapsed = node.data.collapsed;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
collapsed: !collapsed,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
const selectMatch = Editor.above(editor, {
|
||||
match: (n) => Element.isElement(n) && n.blockId !== undefined,
|
||||
});
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
|
||||
if (selectMatch) {
|
||||
const [selectNode] = selectMatch;
|
||||
const selectNodePath = ReactEditor.findPath(editor, selectNode);
|
||||
|
||||
if (Path.isAncestor(path, selectNodePath)) {
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
icon: newIcon,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
@ -203,8 +312,10 @@ export const CustomEditor = {
|
||||
|
||||
setMathEquationBlockFormula(editor: ReactEditor, node: Element, newFormula: string) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
formula: newFormula,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
@ -214,8 +325,10 @@ export const CustomEditor = {
|
||||
|
||||
setGridBlockViewId(editor: ReactEditor, node: Element, newViewId: string) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
viewId: newViewId,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
@ -227,13 +340,19 @@ export const CustomEditor = {
|
||||
const cloneNode: Element = {
|
||||
...cloneDeep(block),
|
||||
blockId: generateId(),
|
||||
type: block.type === EditorNodeType.Page ? EditorNodeType.Paragraph : block.type,
|
||||
children: [],
|
||||
};
|
||||
const isEmbed = editor.isEmbed(cloneNode);
|
||||
|
||||
if (isEmbed) {
|
||||
return cloneNode;
|
||||
}
|
||||
|
||||
const [firstTextNode, ...children] = block.children as Element[];
|
||||
const isSelectable = editor.isSelectable(cloneNode);
|
||||
|
||||
const textNode =
|
||||
firstTextNode && firstTextNode.type === EditorNodeType.Text && isSelectable
|
||||
firstTextNode && firstTextNode.type === EditorNodeType.Text
|
||||
? {
|
||||
textId: generateId(),
|
||||
type: EditorNodeType.Text,
|
||||
@ -259,7 +378,10 @@ export const CustomEditor = {
|
||||
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
const nextPath = Path.next(path);
|
||||
|
||||
Transforms.insertNodes(editor, cloneNode, { at: nextPath });
|
||||
return cloneNode;
|
||||
},
|
||||
|
||||
deleteNode(editor: ReactEditor, node: Node) {
|
||||
@ -268,6 +390,9 @@ export const CustomEditor = {
|
||||
Transforms.removeNodes(editor, {
|
||||
at: path,
|
||||
});
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
},
|
||||
|
||||
getBlockType: (editor: ReactEditor) => {
|
||||
@ -292,7 +417,7 @@ export const CustomEditor = {
|
||||
return CustomEditor.getBlockType(editor) === EditorNodeType.CodeBlock;
|
||||
},
|
||||
|
||||
insertEmptyLineAtEnd: (editor: ReactEditor & YjsEditor) => {
|
||||
insertEmptyLine: (editor: ReactEditor & YjsEditor, path: Path) => {
|
||||
editor.insertNode(
|
||||
{
|
||||
type: EditorNodeType.Paragraph,
|
||||
@ -312,13 +437,17 @@ export const CustomEditor = {
|
||||
},
|
||||
{
|
||||
select: true,
|
||||
at: [editor.children.length],
|
||||
at: path,
|
||||
}
|
||||
);
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.move(editor);
|
||||
},
|
||||
|
||||
insertEmptyLineAtEnd: (editor: ReactEditor & YjsEditor) => {
|
||||
CustomEditor.insertEmptyLine(editor, [editor.children.length]);
|
||||
},
|
||||
|
||||
focusAtStartOfBlock(editor: ReactEditor) {
|
||||
const { selection } = editor;
|
||||
|
||||
@ -342,11 +471,17 @@ export const CustomEditor = {
|
||||
}
|
||||
) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
const nodeData = node.data || {};
|
||||
const newProperties = {
|
||||
data,
|
||||
data: {
|
||||
...nodeData,
|
||||
...data,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
editor.select(path);
|
||||
},
|
||||
|
||||
deleteAllText(editor: ReactEditor, node: Element) {
|
||||
@ -383,4 +518,20 @@ export const CustomEditor = {
|
||||
|
||||
return editor.isEmpty(textNode);
|
||||
},
|
||||
|
||||
getNodeTextContent(node: Node): string {
|
||||
if (Element.isElement(node) && node.type === EditorInlineNodeType.Formula) {
|
||||
return (node as FormulaNode).data || '';
|
||||
}
|
||||
|
||||
if (Text.isText(node)) {
|
||||
return node.text || '';
|
||||
}
|
||||
|
||||
return node.children.map((n) => CustomEditor.getNodeTextContent(n)).join('');
|
||||
},
|
||||
|
||||
isEmbedNode(node: Element): boolean {
|
||||
return EmbedTypes.includes(node.type);
|
||||
},
|
||||
};
|
||||
|
@ -13,13 +13,18 @@ export function toggleMark(
|
||||
|
||||
const isActive = isMarkActive(editor, key);
|
||||
|
||||
if (isActive) {
|
||||
if (isActive || !value) {
|
||||
Editor.removeMark(editor, key as string);
|
||||
} else {
|
||||
} else if (value) {
|
||||
Editor.addMark(editor, key as string, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the every text in the selection has the mark.
|
||||
* @param editor
|
||||
* @param format
|
||||
*/
|
||||
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
|
||||
const selection = editor.selection;
|
||||
|
||||
@ -27,6 +32,34 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
|
||||
|
||||
const isExpanded = Range.isExpanded(selection);
|
||||
|
||||
if (isExpanded) {
|
||||
const matches = Array.from(getSelectionNodeEntry(editor) || []);
|
||||
|
||||
return matches.every((match) => {
|
||||
const [node] = match;
|
||||
|
||||
const { text, ...attributes } = node;
|
||||
|
||||
if (!text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(attributes as Record<string, boolean | string>)[format];
|
||||
});
|
||||
}
|
||||
|
||||
const marks = Editor.marks(editor) as Record<string, string | boolean> | null;
|
||||
|
||||
return marks ? !!marks[format] : false;
|
||||
}
|
||||
|
||||
function getSelectionNodeEntry(editor: ReactEditor) {
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!selection) return null;
|
||||
|
||||
const isExpanded = Range.isExpanded(selection);
|
||||
|
||||
if (isExpanded) {
|
||||
let anchor = Range.start(selection);
|
||||
const focus = Range.end(selection);
|
||||
@ -40,36 +73,52 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
|
||||
}
|
||||
}
|
||||
|
||||
const matches = Array.from(
|
||||
Editor.nodes(editor, {
|
||||
match: Text.isText,
|
||||
at: {
|
||||
anchor,
|
||||
focus,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return matches.every((match) => {
|
||||
const [node] = match;
|
||||
|
||||
const { text, ...attributes } = node;
|
||||
|
||||
if (!text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!attributes[format];
|
||||
return Editor.nodes(editor, {
|
||||
match: Text.isText,
|
||||
at: {
|
||||
anchor,
|
||||
focus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const marks = Editor.marks(editor) as Record<string, string | boolean> | null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return marks ? !!marks[format] : false;
|
||||
/**
|
||||
* Get all marks in the current selection.
|
||||
* @param editor
|
||||
*/
|
||||
export function getAllMarks(editor: ReactEditor) {
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!selection) return null;
|
||||
|
||||
const isExpanded = Range.isExpanded(selection);
|
||||
|
||||
if (isExpanded) {
|
||||
const matches = Array.from(getSelectionNodeEntry(editor) || []);
|
||||
|
||||
const marks: Record<string, string | boolean> = {};
|
||||
|
||||
matches.forEach((match) => {
|
||||
const [node] = match;
|
||||
|
||||
Object.entries(node).forEach(([key, value]) => {
|
||||
if (key !== 'text') {
|
||||
marks[key] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return marks;
|
||||
}
|
||||
|
||||
return Editor.marks(editor) as Record<string, string | boolean> | null;
|
||||
}
|
||||
|
||||
export function removeMarks(editor: ReactEditor) {
|
||||
const marks = Editor.marks(editor);
|
||||
const marks = getAllMarks(editor);
|
||||
|
||||
if (!marks) return;
|
||||
|
||||
|
@ -12,16 +12,6 @@ export const LIST_TYPES = [
|
||||
EditorNodeType.Paragraph,
|
||||
];
|
||||
|
||||
const LIST_ITEM_TYPES = [
|
||||
EditorNodeType.NumberedListBlock,
|
||||
EditorNodeType.BulletedListBlock,
|
||||
EditorNodeType.TodoListBlock,
|
||||
EditorNodeType.ToggleListBlock,
|
||||
EditorNodeType.QuoteBlock,
|
||||
EditorNodeType.Paragraph,
|
||||
EditorNodeType.HeadingBlock,
|
||||
];
|
||||
|
||||
/**
|
||||
* Indent the current list item
|
||||
* Conditions:
|
||||
@ -41,11 +31,6 @@ export function tabForward(editor: ReactEditor) {
|
||||
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
|
||||
// the node is not a list item
|
||||
if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPath = Path.previous(path);
|
||||
|
||||
const previous = editor.node(previousPath);
|
||||
@ -84,12 +69,6 @@ export function tabBackward(editor: ReactEditor) {
|
||||
const depth = path.length;
|
||||
|
||||
if (node.type === EditorNodeType.Page) return;
|
||||
if (node.type !== EditorNodeType.Paragraph) {
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth === 1) return;
|
||||
editor.liftNodes({
|
||||
|
@ -1,18 +1,20 @@
|
||||
import React, { CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSelected, useSlate } from 'slate-react';
|
||||
import { Editor, Element, Range } from 'slate';
|
||||
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
const editor = useSlate();
|
||||
const selected = useSelected() && !!editor.selection && Range.isCollapsed(editor.selection);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const block = useMemo(() => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const match = Editor.above(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined;
|
||||
},
|
||||
at: path,
|
||||
});
|
||||
|
||||
@ -22,9 +24,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
}, [editor, node]);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return `pointer-events-none select-none mx-1 absolute left-0.5 min-h-[26px] top-0 whitespace-nowrap text-text-placeholder ${
|
||||
attributes.className ?? ''
|
||||
}`;
|
||||
return `text-placeholder ${attributes.className ?? ''}`;
|
||||
}, [attributes.className]);
|
||||
|
||||
const unSelectedPlaceholder = useMemo(() => {
|
||||
@ -81,6 +81,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
case EditorNodeType.GridBlock:
|
||||
case EditorNodeType.EquationBlock:
|
||||
case EditorNodeType.CodeBlock:
|
||||
case EditorNodeType.DividerBlock:
|
||||
return '';
|
||||
|
||||
default:
|
||||
@ -114,9 +115,12 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
}
|
||||
|
||||
return (
|
||||
<span contentEditable={false} {...attributes} className={className}>
|
||||
{selected ? selectedPlaceholder : unSelectedPlaceholder}
|
||||
</span>
|
||||
<span
|
||||
placeholder={selected ? selectedPlaceholder : unSelectedPlaceholder}
|
||||
contentEditable={false}
|
||||
{...attributes}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -5,14 +5,9 @@ export const BulletedList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(
|
||||
({ node: _, children, className, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center pt-[3px] font-medium'}>
|
||||
•
|
||||
</span>
|
||||
<div ref={ref} {...attributes} className={`${className} pl-6`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
<div ref={ref} {...attributes} className={`${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { BulletedListNode } from '$app/application/document/document.types';
|
||||
|
||||
function BulletedListIcon({ block: _, className }: { block: BulletedListNode; className: string }) {
|
||||
return (
|
||||
<span
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
contentEditable={false}
|
||||
className={`${className} bulleted-icon flex w-[23px] justify-center pr-1 font-medium`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BulletedListIcon;
|
@ -6,17 +6,15 @@ export const Callout = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-3'}>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-[15px]'}>
|
||||
<CalloutIcon node={node} />
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} my-2 flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
|
||||
>
|
||||
{children}
|
||||
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
|
||||
<div
|
||||
className={`flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -4,19 +4,30 @@ import { CalloutNode } from '$app/application/document/document.types';
|
||||
import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
function CalloutIcon({ node }: { node: CalloutNode }) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const editor = useSlateStatic();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}, [editor, node]);
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
CustomEditor.setCalloutIcon(editor, node, emoji);
|
||||
handleClose();
|
||||
},
|
||||
[editor, node]
|
||||
[editor, node, handleClose]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -38,11 +49,9 @@ function CalloutIcon({ node }: { node: CalloutNode }) {
|
||||
anchorEl={ref.current}
|
||||
disableAutoFocus={true}
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
@ -50,7 +59,7 @@ function CalloutIcon({ node }: { node: CalloutNode }) {
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
||||
<EmojiPicker onEscape={handleClose} onEmojiSelect={handleEmojiSelect} />
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,26 +1,35 @@
|
||||
import { forwardRef, memo } from 'react';
|
||||
import { forwardRef, memo, useCallback } from 'react';
|
||||
import { EditorElementProps, CodeNode } from '$app/application/document/document.types';
|
||||
import LanguageSelect from './SelectLanguage';
|
||||
|
||||
import { useCodeBlock } from '$app/components/editor/components/blocks/code/Code.hooks';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
|
||||
export const Code = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<CodeNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { language, handleChangeLanguage } = useCodeBlock(node);
|
||||
|
||||
const editor = useSlateStatic();
|
||||
const onBlur = useCallback(() => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
editor.select(path);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}, [editor, node]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-7 py-6'}>
|
||||
<LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} />
|
||||
<div contentEditable={false} className={'absolute mt-2 flex h-20 w-full select-none items-center px-6'}>
|
||||
<LanguageSelect onBlur={onBlur} language={language} onChangeLanguage={handleChangeLanguage} />
|
||||
</div>
|
||||
<div
|
||||
{...attributes}
|
||||
ref={ref}
|
||||
className={`${
|
||||
attributes.className ?? ''
|
||||
} my-2 flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6 pt-14`}
|
||||
>
|
||||
<pre>
|
||||
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} flex w-full bg-bg-body py-2`}>
|
||||
<pre
|
||||
spellCheck={false}
|
||||
className={`flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-5 pt-20`}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
@ -1,36 +1,161 @@
|
||||
import React from 'react';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TextField, Popover } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportLanguage } from './constants';
|
||||
import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
|
||||
const initialOrigin: {
|
||||
transformOrigin: PopoverOrigin;
|
||||
anchorOrigin: PopoverOrigin;
|
||||
} = {
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
},
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
},
|
||||
};
|
||||
|
||||
function SelectLanguage({
|
||||
language,
|
||||
language = 'json',
|
||||
onChangeLanguage,
|
||||
onBlur,
|
||||
}: {
|
||||
language: string;
|
||||
onChangeLanguage: (language: string) => void;
|
||||
onBlur?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const options: KeyboardNavigationOption[] = useMemo(() => {
|
||||
return supportLanguage
|
||||
.map((item) => ({
|
||||
key: item.id,
|
||||
content: item.title,
|
||||
}))
|
||||
.filter((item) => {
|
||||
return item.content?.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
}, [search]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(key: string) => {
|
||||
onChangeLanguage(key);
|
||||
handleClose();
|
||||
},
|
||||
[onChangeLanguage, handleClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
|
||||
if (!element) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
element.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onBlur]);
|
||||
|
||||
const { paperHeight, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
|
||||
initialPaperWidth: 200,
|
||||
initialPaperHeight: 220,
|
||||
anchorEl: ref.current,
|
||||
initialAnchorOrigin: initialOrigin.anchorOrigin,
|
||||
initialTransformOrigin: initialOrigin.transformOrigin,
|
||||
open,
|
||||
});
|
||||
|
||||
return (
|
||||
<FormControl variant='standard'>
|
||||
<Select
|
||||
<>
|
||||
<TextField
|
||||
ref={ref}
|
||||
size={'small'}
|
||||
className={'h-[28px] w-[150px]'}
|
||||
value={language || 'javascript'}
|
||||
onChange={(event: SelectChangeEvent) => onChangeLanguage(event.target.value)}
|
||||
variant={'standard'}
|
||||
className={'w-[150px]'}
|
||||
value={language}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
placeholder={t('document.codeBlock.language.placeholder')}
|
||||
label={t('document.codeBlock.language.label')}
|
||||
>
|
||||
{supportLanguage.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<Popover
|
||||
disableAutoFocus={true}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
anchorEl={ref.current}
|
||||
keepMounted={false}
|
||||
open={open && isEntered}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: paperHeight,
|
||||
}}
|
||||
className={'flex max-h-[220px] w-[200px] flex-col overflow-hidden py-2'}
|
||||
>
|
||||
<TextField
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
autoComplete={'off'}
|
||||
spellCheck={false}
|
||||
autoCorrect={'off'}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size={'small'}
|
||||
autoFocus={true}
|
||||
variant={'standard'}
|
||||
className={'px-2 text-xs'}
|
||||
placeholder={t('search.label')}
|
||||
/>
|
||||
<div ref={scrollRef} className={'flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||
<KeyboardNavigation
|
||||
disableFocus={true}
|
||||
focusRef={searchRef}
|
||||
onConfirm={handleConfirm}
|
||||
defaultFocusedKey={language}
|
||||
scrollRef={scrollRef}
|
||||
options={options}
|
||||
onEscape={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,68 @@
|
||||
export const supportLanguage = [
|
||||
{
|
||||
id: 'bash',
|
||||
title: 'Bash',
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
title: 'Basic',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
title: 'C',
|
||||
},
|
||||
{
|
||||
id: 'clojure',
|
||||
title: 'Clojure',
|
||||
},
|
||||
{
|
||||
id: 'cpp',
|
||||
title: 'C++',
|
||||
},
|
||||
{
|
||||
id: 'cs',
|
||||
title: 'CS',
|
||||
},
|
||||
{
|
||||
id: 'css',
|
||||
title: 'CSS',
|
||||
},
|
||||
{
|
||||
id: 'dart',
|
||||
title: 'Dart',
|
||||
},
|
||||
{
|
||||
id: 'elixir',
|
||||
title: 'Elixir',
|
||||
},
|
||||
{
|
||||
id: 'elm',
|
||||
title: 'Elm',
|
||||
},
|
||||
{
|
||||
id: 'erlang',
|
||||
title: 'Erlang',
|
||||
},
|
||||
{
|
||||
id: 'fortran',
|
||||
title: 'Fortran',
|
||||
},
|
||||
{
|
||||
id: 'go',
|
||||
title: 'Go',
|
||||
},
|
||||
{
|
||||
id: 'graphql',
|
||||
title: 'GraphQL',
|
||||
},
|
||||
{
|
||||
id: 'haskell',
|
||||
title: 'Haskell',
|
||||
},
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java',
|
||||
},
|
||||
{
|
||||
id: 'javascript',
|
||||
title: 'JavaScript',
|
||||
@ -8,12 +72,83 @@ export const supportLanguage = [
|
||||
title: 'JSON',
|
||||
},
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
id: 'kotlin',
|
||||
title: 'Kotlin',
|
||||
},
|
||||
{
|
||||
id: 'lisp',
|
||||
title: 'Lisp',
|
||||
},
|
||||
{
|
||||
id: 'lua',
|
||||
title: 'Lua',
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: 'Markdown',
|
||||
},
|
||||
{
|
||||
id: 'matlab',
|
||||
title: 'Matlab',
|
||||
},
|
||||
{
|
||||
id: 'ocaml',
|
||||
title: 'OCaml',
|
||||
},
|
||||
{
|
||||
id: 'perl',
|
||||
title: 'Perl',
|
||||
},
|
||||
{
|
||||
id: 'php',
|
||||
title: 'PHP',
|
||||
},
|
||||
{
|
||||
id: 'powershell',
|
||||
title: 'Powershell',
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
title: 'Python',
|
||||
},
|
||||
{
|
||||
id: 'r',
|
||||
title: 'R',
|
||||
},
|
||||
{
|
||||
id: 'ruby',
|
||||
title: 'Ruby',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'rust',
|
||||
title: 'Rust',
|
||||
},
|
||||
{
|
||||
id: 'scala',
|
||||
title: 'Scala',
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
title: 'Shell',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
title: 'SQL',
|
||||
},
|
||||
{
|
||||
id: 'swift',
|
||||
title: 'Swift',
|
||||
},
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
},
|
||||
{
|
||||
id: 'xml',
|
||||
title: 'XML',
|
||||
},
|
||||
{
|
||||
id: 'yaml',
|
||||
title: 'YAML',
|
||||
},
|
||||
];
|
||||
|
@ -1,9 +1,43 @@
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-basic';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-clojure';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csp';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-dart';
|
||||
import 'prismjs/components/prism-elixir';
|
||||
import 'prismjs/components/prism-elm';
|
||||
import 'prismjs/components/prism-erlang';
|
||||
import 'prismjs/components/prism-fortran';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-graphql';
|
||||
import 'prismjs/components/prism-haskell';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-lisp';
|
||||
import 'prismjs/components/prism-lua';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-matlab';
|
||||
import 'prismjs/components/prism-ocaml';
|
||||
import 'prismjs/components/prism-perl';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-powershell';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-r';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-scala';
|
||||
import 'prismjs/components/prism-shell-session';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-xml-doc';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
|
||||
import { BaseRange, NodeEntry, Text, Path } from 'slate';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
|
||||
|
||||
import { GridNode } from '$app/application/document/document.types';
|
||||
@ -12,21 +12,12 @@ function DatabaseEmpty({ node }: { node: GridNode }) {
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||
if (
|
||||
event &&
|
||||
event.type === 'keydown' &&
|
||||
((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === 'click') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
};
|
||||
const toggleDrawer = useCallback((open: boolean) => {
|
||||
return (e: React.MouseEvent | KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(open);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -1,23 +1,56 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { List, MenuItem, TextField } from '@mui/material';
|
||||
import { TextField } from '@mui/material';
|
||||
import { useLoadDatabaseList } from '$app/components/editor/components/blocks/database/DatabaseList.hooks';
|
||||
import { ViewLayoutPB } from '@/services/backend';
|
||||
import { ReactComponent as GridSvg } from '$app/assets/grid.svg';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { GridNode } from '$app/application/document/document.types';
|
||||
import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { Page } from '$app_reducers/pages/slice';
|
||||
|
||||
function DatabaseList({ node }: { node: GridNode }) {
|
||||
function DatabaseList({
|
||||
node,
|
||||
toggleDrawer,
|
||||
}: {
|
||||
node: GridNode;
|
||||
toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent) => void;
|
||||
}) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const inputRef = React.useRef<HTMLElement>(null);
|
||||
const editor = useSlateStatic();
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = React.useState<string>('');
|
||||
const [hovered, setHovered] = React.useState<string | null>(null);
|
||||
const { list } = useLoadDatabaseList({
|
||||
searchText: searchText || '',
|
||||
layout: ViewLayoutPB.Grid,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: Page) => {
|
||||
return (
|
||||
<div className={'flex items-center text-text-title'}>
|
||||
<GridSvg className={'mr-2 h-4 w-4'} />
|
||||
<div className={'truncate'}>{item.name || t('document.title.placeholder')}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const options: KeyboardNavigationOption[] = useMemo(() => {
|
||||
return list.map((item) => {
|
||||
return {
|
||||
key: item.id,
|
||||
content: renderItem(item),
|
||||
};
|
||||
});
|
||||
}, [list, renderItem]);
|
||||
|
||||
const handleSelected = useCallback(
|
||||
(id: string) => {
|
||||
CustomEditor.setGridBlockViewId(editor, node, id);
|
||||
@ -25,53 +58,23 @@ function DatabaseList({ node }: { node: GridNode }) {
|
||||
[editor, node]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length > 0) {
|
||||
setHovered(list[0].id);
|
||||
}
|
||||
}, [list]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const index = list.findIndex((item) => item.id === hovered);
|
||||
const prevIndex = index - 1;
|
||||
const nextIndex = index + 1;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (nextIndex < list.length) {
|
||||
setHovered(list[nextIndex].id);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (prevIndex >= 0) {
|
||||
setHovered(list[prevIndex].id);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.stopPropagation();
|
||||
if (hovered) {
|
||||
handleSelected(hovered);
|
||||
}
|
||||
|
||||
break;
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDrawer(false)(e);
|
||||
}
|
||||
},
|
||||
[handleSelected, hovered, list]
|
||||
[toggleDrawer]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'relative overflow-y-auto overflow-x-hidden p-2'}>
|
||||
<div className={'relative flex flex-col gap-1.5 p-2'}>
|
||||
<TextField
|
||||
onKeyDown={handleKeyDown}
|
||||
variant={'standard'}
|
||||
autoFocus={true}
|
||||
inputRef={inputRef}
|
||||
className={'sticky top-0 z-10 px-2'}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
@ -82,28 +85,16 @@ function DatabaseList({ node }: { node: GridNode }) {
|
||||
}}
|
||||
placeholder={t('document.plugins.database.linkToDatabase')}
|
||||
/>
|
||||
<List>
|
||||
{list.map((item) => {
|
||||
return (
|
||||
<MenuItem
|
||||
onMouseEnter={() => {
|
||||
setHovered(item.id);
|
||||
}}
|
||||
selected={hovered === item.id}
|
||||
key={item.id}
|
||||
className={'flex items-center justify-between'}
|
||||
onClick={() => {
|
||||
handleSelected(item.id);
|
||||
}}
|
||||
>
|
||||
<div className={'flex items-center text-text-title'}>
|
||||
<GridSvg className={'mr-2 h-4 w-4'} />
|
||||
<div className={'truncate'}>{item.name || t('document.title.placeholder')}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<div ref={scrollRef} className={'flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||
<KeyboardNavigation
|
||||
disableFocus={true}
|
||||
onKeyDown={handleKeyDown}
|
||||
focusRef={inputRef}
|
||||
scrollRef={scrollRef}
|
||||
options={options}
|
||||
onConfirm={handleSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ function Drawer({
|
||||
node,
|
||||
}: {
|
||||
open: boolean;
|
||||
toggleDrawer: (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => void;
|
||||
toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent) => void;
|
||||
node: GridNode;
|
||||
}) {
|
||||
const editor = useSlateStatic();
|
||||
@ -46,7 +46,7 @@ function Drawer({
|
||||
<CloseSvg />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={'flex-1'}>{open && <DatabaseList node={node} />}</div>
|
||||
<div className={'flex-1'}>{open && <DatabaseList toggleDrawer={toggleDrawer} node={node} />}</div>
|
||||
|
||||
<div
|
||||
onClick={handleCreateGrid}
|
||||
|
@ -1,30 +1,25 @@
|
||||
import React, { forwardRef, memo, useCallback, useContext } from 'react';
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, GridNode } from '$app/application/document/document.types';
|
||||
|
||||
import GridView from '$app/components/editor/components/blocks/database/GridView';
|
||||
import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
import { useSelected } from 'slate-react';
|
||||
|
||||
export const GridBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children, className = '', ...attributes }, ref) => {
|
||||
const viewId = node.data.viewId;
|
||||
|
||||
const blockId = node.blockId;
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
const onClick = useCallback(() => {
|
||||
if (!blockId) return;
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(blockId);
|
||||
}, [blockId, selectedBlockContext]);
|
||||
const selected = useSelected();
|
||||
|
||||
return (
|
||||
<div {...attributes} onClick={onClick} className={`${className} relative my-2 w-full`}>
|
||||
<div {...attributes} className={`${className} grid-block relative w-full bg-bg-body py-2`}>
|
||||
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
contentEditable={false}
|
||||
className='flex h-[400px] w-full overflow-hidden border-b border-t border-line-divider bg-bg-body py-3 caret-text-title'
|
||||
className={`flex h-[400px] w-full overflow-hidden border-b border-t ${
|
||||
selected ? 'border-fill-list-hover' : 'border-line-divider'
|
||||
} py-3 caret-text-title`}
|
||||
>
|
||||
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
|
||||
</div>
|
||||
|
@ -1,13 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database } from '$app/components/database';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Database, DatabaseRenderedProvider } from '$app/components/database';
|
||||
import { ViewIdProvider } from '$app/hooks';
|
||||
|
||||
function GridView({ viewId }: { viewId: string }) {
|
||||
const [selectedViewId, onChangeSelectedViewId] = useState(viewId);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [rendered, setRendered] = useState(false);
|
||||
|
||||
// delegate wheel event to layout when grid is scrolled to top or bottom
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gridScroller = element.querySelector('.grid-scroll-container') as HTMLDivElement;
|
||||
|
||||
const scrollLayout = gridScroller?.closest('.appflowy-layout') as HTMLDivElement;
|
||||
|
||||
if (!gridScroller || !scrollLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
const deltaY = event.deltaY;
|
||||
const deltaX = event.deltaX;
|
||||
|
||||
if (deltaX > 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = gridScroller;
|
||||
|
||||
const atTop = deltaY < 0 && scrollTop === 0;
|
||||
const atBottom = deltaY > 0 && scrollTop + clientHeight >= scrollHeight;
|
||||
|
||||
// if at top or bottom, prevent default to allow layout to scroll
|
||||
if (atTop || atBottom) {
|
||||
scrollLayout.scrollTop += deltaY;
|
||||
}
|
||||
};
|
||||
|
||||
gridScroller.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
gridScroller.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [rendered]);
|
||||
|
||||
const onRendered = useCallback(() => {
|
||||
setRendered(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ViewIdProvider value={viewId}>
|
||||
<Database selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} />
|
||||
<DatabaseRenderedProvider value={onRendered}>
|
||||
<Database ref={ref} selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} />
|
||||
</DatabaseRenderedProvider>
|
||||
</ViewIdProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,12 +1,21 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, DividerNode as DividerNodeType } from '$app/application/document/document.types';
|
||||
import { useSelected } from 'slate-react';
|
||||
|
||||
export const DividerNode = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>(
|
||||
({ node: _node, children: children, className, ...attributes }, ref) => {
|
||||
({ node: _node, children: children, ...attributes }, ref) => {
|
||||
const selected = useSelected();
|
||||
|
||||
const className = useMemo(() => {
|
||||
return `${attributes.className ?? ''} divider-node relative w-full rounded ${
|
||||
selected ? 'bg-content-blue-100' : ''
|
||||
}`;
|
||||
}, [attributes.className, selected]);
|
||||
|
||||
return (
|
||||
<div {...attributes} className={`${className} relative w-full`}>
|
||||
<div contentEditable={false} className={'w-full py-3 text-line-divider'}>
|
||||
<div {...attributes} className={className}>
|
||||
<div contentEditable={false} className={'w-full py-2 text-line-divider'}>
|
||||
<hr />
|
||||
</div>
|
||||
<div ref={ref} className={`absolute h-full w-full caret-transparent`}>
|
||||
|
@ -1,13 +1,29 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { Element } from 'slate';
|
||||
import { KeyboardReturnOutlined } from '@mui/icons-material';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { MathEquationNode } from '$app/application/document/document.types';
|
||||
import katex from 'katex';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||
|
||||
const initialOrigin: {
|
||||
transformOrigin: PopoverOrigin;
|
||||
anchorOrigin: PopoverOrigin;
|
||||
} = {
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
},
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
},
|
||||
};
|
||||
|
||||
function EditPopover({
|
||||
open,
|
||||
@ -16,14 +32,18 @@ function EditPopover({
|
||||
node,
|
||||
}: {
|
||||
open: boolean;
|
||||
node: Element | null;
|
||||
node: MathEquationNode;
|
||||
anchorEl: HTMLDivElement | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const editor = useSlateStatic();
|
||||
|
||||
const [error, setError] = useState<{
|
||||
name: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [value, setValue] = useState<string>(node.data.formula || '');
|
||||
const onInput = (event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
setValue(event.currentTarget.value);
|
||||
};
|
||||
@ -39,11 +59,15 @@ function EditPopover({
|
||||
|
||||
const handleDone = () => {
|
||||
if (!node) return;
|
||||
CustomEditor.setMathEquationBlockFormula(editor, node, value);
|
||||
if (value !== node.data.formula) {
|
||||
CustomEditor.setMathEquationBlockFormula(editor, node, value);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
const shift = e.shiftKey;
|
||||
|
||||
// If shift is pressed, allow the user to enter a new line, otherwise close the popover
|
||||
@ -52,40 +76,87 @@ function EditPopover({
|
||||
e.stopPropagation();
|
||||
handleDone();
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
katex.render(value, document.createElement('div'));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e as {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const { transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({
|
||||
initialPaperWidth: 300,
|
||||
initialPaperHeight: 200,
|
||||
anchorEl,
|
||||
initialAnchorOrigin: initialOrigin.anchorOrigin,
|
||||
initialTransformOrigin: initialOrigin.transformOrigin,
|
||||
open,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
{...PopoverCommonProps}
|
||||
open={open}
|
||||
open={open && isEntered}
|
||||
anchorEl={anchorEl}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={transformOrigin}
|
||||
anchorOrigin={anchorOrigin}
|
||||
onClose={handleClose}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className={'flex flex-col gap-3 p-4'}>
|
||||
<TextareaAutosize
|
||||
className='w-full resize-none whitespace-break-spaces break-all rounded border p-2 text-sm'
|
||||
autoFocus
|
||||
autoCorrect='off'
|
||||
autoComplete={'off'}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
minRows={3}
|
||||
onInput={onInput}
|
||||
onKeyDown={handleEnter}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={`|x| = \\begin{cases}
|
||||
x, &\\quad x \\geq 0 \\\\
|
||||
-x, &\\quad x < 0
|
||||
\\end{cases}`}
|
||||
/>
|
||||
<Button endIcon={<KeyboardReturnOutlined />} variant={'outlined'} onClick={handleDone}>
|
||||
{t('button.done')}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className={'max-w-[270px] text-sm text-red-500'}>
|
||||
{error.name}: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={'flex justify-between gap-2'}>
|
||||
<Button
|
||||
size={'small'}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
onClick={handleClose}
|
||||
className={'flex-grow text-text-caption'}
|
||||
>
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button disabled={!!error} size={'small'} variant={'contained'} onClick={handleDone} className={'flex-grow'}>
|
||||
{t('button.done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
|
@ -1,30 +1,59 @@
|
||||
import { forwardRef, memo, useState } from 'react';
|
||||
import { forwardRef, memo, useEffect, useRef, useState } from 'react';
|
||||
import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types';
|
||||
import KatexMath from '$app/components/_shared/katex_math/KatexMath';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FunctionsOutlined } from '@mui/icons-material';
|
||||
import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover';
|
||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||
|
||||
export const MathEquation = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>(
|
||||
({ node, children, className, ...attributes }, ref) => {
|
||||
const formula = node.data.formula;
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useSelected();
|
||||
|
||||
const editor = useSlateStatic();
|
||||
|
||||
useEffect(() => {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
if (!slateDom) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (selected) {
|
||||
slateDom.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
slateDom.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [editor, selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...attributes}
|
||||
onClick={(e) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
ref={containerRef}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
className={`${className} relative my-2 w-full cursor-pointer`}
|
||||
className={`${className} relative w-full cursor-pointer py-2`}
|
||||
>
|
||||
<div
|
||||
contentEditable={false}
|
||||
className={`w-full select-none rounded border border-line-divider bg-content-blue-50 px-3`}
|
||||
className={`w-full select-none rounded border border-line-divider ${
|
||||
selected ? 'border-fill-hover' : ''
|
||||
} bg-content-blue-50 px-3`}
|
||||
>
|
||||
{formula ? (
|
||||
<KatexMath latex={formula} />
|
||||
@ -42,11 +71,11 @@ export const MathEquation = memo(
|
||||
{open && (
|
||||
<EditPopover
|
||||
onClose={() => {
|
||||
setAnchorEl(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
node={node}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
anchorEl={containerRef.current}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -0,0 +1,44 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Element, Path } from 'slate';
|
||||
import { NumberedListNode } from '$app/application/document/document.types';
|
||||
|
||||
function NumberListIcon({ block, className }: { block: NumberedListNode; className: string }) {
|
||||
const editor = useSlate();
|
||||
|
||||
const path = ReactEditor.findPath(editor, block);
|
||||
const index = useMemo(() => {
|
||||
let index = 1;
|
||||
|
||||
let prevPath = Path.previous(path);
|
||||
|
||||
while (prevPath) {
|
||||
const prev = editor.node(prevPath);
|
||||
|
||||
const prevNode = prev[0] as Element;
|
||||
|
||||
if (prevNode.type === block.type) {
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
prevPath = Path.previous(prevPath);
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [editor, block, path]);
|
||||
|
||||
return (
|
||||
<span
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
contentEditable={false}
|
||||
data-number={index}
|
||||
className={`${className} numbered-icon flex w-[23px] justify-center pr-1 font-medium`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberListIcon;
|
@ -1,46 +1,13 @@
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, NumberedListNode } from '$app/application/document/document.types';
|
||||
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Element, Path } from 'slate';
|
||||
|
||||
export const NumberedList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(
|
||||
({ node, children, className, ...attributes }, ref) => {
|
||||
const editor = useSlate();
|
||||
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const index = useMemo(() => {
|
||||
let index = 1;
|
||||
|
||||
let prevPath = Path.previous(path);
|
||||
|
||||
while (prevPath) {
|
||||
const prev = editor.node(prevPath);
|
||||
|
||||
const prevNode = prev[0] as Element;
|
||||
|
||||
if (prevNode.type === node.type) {
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
prevPath = Path.previous(prevPath);
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [editor, node, path]);
|
||||
|
||||
({ node: _, children, className, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center pt-[3px] font-medium'}>
|
||||
{index}.
|
||||
</span>
|
||||
<div ref={ref} {...attributes} className={`${className} pl-6`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
<div ref={ref} {...attributes} className={`${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
@ -4,7 +4,7 @@ import { EditorElementProps, PageNode } from '$app/application/document/document
|
||||
export const Page = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => {
|
||||
const className = useMemo(() => {
|
||||
return `${attributes.className ?? ''} pb-3 min-h-[52px] text-4xl font-bold`;
|
||||
return `${attributes.className ?? ''} pb-3 text-4xl font-bold`;
|
||||
}, [attributes.className]);
|
||||
|
||||
return (
|
||||
|
@ -4,7 +4,7 @@ import { EditorElementProps, QuoteNode } from '$app/application/document/documen
|
||||
export const QuoteList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node: _, children, ...attributes }, ref) => {
|
||||
const className = useMemo(() => {
|
||||
return `flex w-full flex-col ml-2.5 border-l-[4px] border-fill-default pl-2.5 ${attributes.className ?? ''}`;
|
||||
return `flex w-full flex-col ml-3 border-l-[4px] border-fill-default pl-2 ${attributes.className ?? ''}`;
|
||||
}, [attributes.className]);
|
||||
|
||||
return (
|
||||
|
@ -0,0 +1,46 @@
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { EditorNodeType, TextNode } from '$app/application/document/document.types';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Editor, Element } from 'slate';
|
||||
import CheckboxIcon from '$app/components/editor/components/blocks/todo_list/CheckboxIcon';
|
||||
import ToggleIcon from '$app/components/editor/components/blocks/toggle_list/ToggleIcon';
|
||||
import NumberListIcon from '$app/components/editor/components/blocks/numbered_list/NumberListIcon';
|
||||
import BulletedListIcon from '$app/components/editor/components/blocks/bulleted_list/BulletedListIcon';
|
||||
|
||||
export function useStartIcon(node: TextNode) {
|
||||
const editor = useSlate();
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const block = Editor.parent(editor, path)?.[0] as Element | null;
|
||||
|
||||
const Component = useMemo(() => {
|
||||
if (!Element.isElement(block)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (block.type) {
|
||||
case EditorNodeType.TodoListBlock:
|
||||
return CheckboxIcon;
|
||||
case EditorNodeType.ToggleListBlock:
|
||||
return ToggleIcon;
|
||||
case EditorNodeType.NumberedListBlock:
|
||||
return NumberListIcon;
|
||||
case EditorNodeType.BulletedListBlock:
|
||||
return BulletedListIcon;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [block]) as FC<{ block: Element; className: string }> | null;
|
||||
|
||||
const renderIcon = useCallback(() => {
|
||||
if (!Component || !block) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component className={`text-block-icon relative select-all`} block={block} />;
|
||||
}, [Component, block]);
|
||||
|
||||
return {
|
||||
hasStartIcon: !!Component,
|
||||
renderIcon,
|
||||
};
|
||||
}
|
@ -2,23 +2,27 @@ import React, { forwardRef, memo } from 'react';
|
||||
import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder';
|
||||
import { EditorElementProps, TextNode } from '$app/application/document/document.types';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { useStartIcon } from '$app/components/editor/components/blocks/text/StartIcon.hooks';
|
||||
|
||||
export const Text = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<TextNode>>(({ node, children, className, ...attributes }, ref) => {
|
||||
const editor = useSlateStatic();
|
||||
const { hasStartIcon, renderIcon } = useStartIcon(node);
|
||||
const isEmpty = editor.isEmpty(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
ref={ref}
|
||||
{...attributes}
|
||||
className={`text-element min-h-[26px] px-1 ${!isEmpty ? 'flex items-center' : 'select-none leading-[26px]'} ${
|
||||
className ?? ''
|
||||
} relative h-full`}
|
||||
className={`text-element relative my-1 flex w-full px-1 ${isEmpty ? 'select-none' : ''} ${className ?? ''} ${
|
||||
hasStartIcon ? 'has-start-icon' : ''
|
||||
}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<Placeholder isEmpty={isEmpty} node={node} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
|
||||
<span className={'min-w-[4px]'}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -0,0 +1,32 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TodoListNode } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
||||
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
||||
|
||||
function CheckboxIcon({ block, className }: { block: TodoListNode; className: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { checked } = block.data;
|
||||
|
||||
const toggleTodo = useCallback(() => {
|
||||
CustomEditor.toggleTodo(editor, block);
|
||||
}, [editor, block]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleTodo}
|
||||
draggable={false}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={`${className} cursor-pointer pr-1 text-xl text-fill-default`}
|
||||
>
|
||||
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxIcon;
|
@ -1,31 +1,15 @@
|
||||
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, TodoListNode } from '$app/application/document/document.types';
|
||||
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
||||
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const TodoList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<TodoListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { checked } = node.data;
|
||||
const editor = useSlateStatic();
|
||||
const className = useMemo(() => {
|
||||
return `flex w-full flex-col pl-6 ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
|
||||
return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
|
||||
}, [attributes.className, checked]);
|
||||
const toggleTodo = useCallback(() => {
|
||||
CustomEditor.toggleTodo(editor, node);
|
||||
}, [editor, node]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleTodo}
|
||||
className='absolute cursor-pointer select-none pt-[3px] text-xl text-fill-default'
|
||||
>
|
||||
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
|
||||
</span>
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -0,0 +1,30 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ToggleListNode } from '$app/application/document/document.types';
|
||||
import { ReactComponent as RightSvg } from '$app/assets/more.svg';
|
||||
|
||||
function ToggleIcon({ block, className }: { block: ToggleListNode; className: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { collapsed } = block.data;
|
||||
|
||||
const toggleToggleList = useCallback(() => {
|
||||
CustomEditor.toggleToggleList(editor, block);
|
||||
}, [editor, block]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleToggleList}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={`${className} cursor-pointer pr-1 text-xl hover:text-fill-default`}
|
||||
>
|
||||
{collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleIcon;
|
@ -1,30 +1,13 @@
|
||||
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { ReactComponent as RightSvg } from '$app/assets/more.svg';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const ToggleList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ToggleListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { collapsed } = node.data;
|
||||
const editor = useSlateStatic() as ReactEditor;
|
||||
const className = useMemo(() => {
|
||||
return `pl-6 ${attributes.className ?? ''} ${collapsed ? 'collapsed' : ''}`;
|
||||
}, [attributes.className, collapsed]);
|
||||
const toggleToggleList = useCallback(() => {
|
||||
CustomEditor.toggleToggleList(editor, node);
|
||||
}, [editor, node]);
|
||||
const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-playwright-selected={false}
|
||||
contentEditable={false}
|
||||
onClick={toggleToggleList}
|
||||
className='absolute cursor-pointer select-none pt-[3px] text-xl hover:text-fill-default'
|
||||
>
|
||||
{collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
|
||||
</span>
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -7,5 +7,15 @@ type CustomEditableProps = Omit<ComponentProps<typeof Editable>, 'renderElement'
|
||||
Partial<Pick<ComponentProps<typeof Editable>, 'renderElement' | 'renderLeaf'>>;
|
||||
|
||||
export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) {
|
||||
return <Editable {...props} renderElement={renderElement} renderLeaf={renderLeaf} />;
|
||||
return (
|
||||
<Editable
|
||||
{...props}
|
||||
autoCorrect={'off'}
|
||||
autoComplete={'off'}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,17 +1,16 @@
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { EditorNodeType, CodeNode } from '$app/application/document/document.types';
|
||||
import { KeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { createEditor, NodeEntry, BaseRange, Editor, Element, Range } from 'slate';
|
||||
import { BaseRange, createEditor, Editor, NodeEntry, Range, Transforms, Element } from 'slate';
|
||||
import { ReactEditor, withReact } from 'slate-react';
|
||||
import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins';
|
||||
import { decorateCode } from '$app/components/editor/components/blocks/code/utils';
|
||||
import { withShortcuts } from '$app/components/editor/components/editor/shortcuts';
|
||||
import { withShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcuts';
|
||||
import { withInlines } from '$app/components/editor/components/inline_nodes';
|
||||
import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core';
|
||||
import { withYHistory, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||
import * as Y from 'yjs';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { proxySet, subscribeKey } from 'valtio/utils';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { CodeNode, EditorNodeType } from '$app/application/document/document.types';
|
||||
import { decorateCode } from '$app/components/editor/components/blocks/code/utils';
|
||||
import isHotkey from 'is-hotkey';
|
||||
|
||||
export function useEditor(sharedType: Y.XmlText) {
|
||||
const editor = useMemo(() => {
|
||||
@ -63,7 +62,12 @@ export function useDecorateCodeHighlight(editor: ReactEditor) {
|
||||
(entry: NodeEntry): BaseRange[] => {
|
||||
const path = entry[1];
|
||||
|
||||
const blockEntry = path.length > 1 ? editor.node([path[0]]) : editor.node(path);
|
||||
const blockEntry = editor.above({
|
||||
at: path,
|
||||
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined,
|
||||
});
|
||||
|
||||
if (!blockEntry) return [];
|
||||
|
||||
const block = blockEntry[0] as CodeNode;
|
||||
|
||||
@ -79,103 +83,38 @@ export function useDecorateCodeHighlight(editor: ReactEditor) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useEditorState(editor: ReactEditor) {
|
||||
const selectedBlocks = useMemo(() => proxySet([]), []);
|
||||
const decorateState = useMemo(
|
||||
() =>
|
||||
proxySet<{
|
||||
range: BaseRange;
|
||||
class_name: string;
|
||||
}>([]),
|
||||
[]
|
||||
);
|
||||
export function useInlineKeyDown(editor: ReactEditor) {
|
||||
return useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const selection = editor.selection;
|
||||
|
||||
const [selectedLength, setSelectedLength] = useState(0);
|
||||
// Default left/right behavior is unit:'character'.
|
||||
// This fails to distinguish between two cursor positions, such as
|
||||
// <inline>foo<cursor/></inline> vs <inline>foo</inline><cursor/>.
|
||||
// Here we modify the behavior to unit:'offset'.
|
||||
// This lets the user step into and out of the inline without stepping over characters.
|
||||
// You may wish to customize this further to only use unit:'offset' in specific cases.
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { nativeEvent } = e;
|
||||
|
||||
const ranges = useSnapshot(decorateState);
|
||||
if (
|
||||
isHotkey('left', nativeEvent) &&
|
||||
CustomEditor.beforeIsInlineNode(editor, selection, {
|
||||
unit: 'offset',
|
||||
})
|
||||
) {
|
||||
e.preventDefault();
|
||||
Transforms.move(editor, { unit: 'offset', reverse: true });
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
|
||||
|
||||
useEffect(() => {
|
||||
const { onChange } = editor;
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!ReactEditor.isFocused(editor) && selectedLength > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selectedBlockId = selectedBlocks.values().next().value;
|
||||
const [selectedBlock] = editor.nodes({
|
||||
at: [],
|
||||
match: (n) => Element.isElement(n) && n.blockId === selectedBlockId,
|
||||
});
|
||||
const [, path] = selectedBlock;
|
||||
|
||||
editor.select(path);
|
||||
ReactEditor.focus(editor);
|
||||
if (isHotkey('right', nativeEvent) && CustomEditor.afterIsInlineNode(editor, selection, { unit: 'offset' })) {
|
||||
e.preventDefault();
|
||||
Transforms.move(editor, { unit: 'offset' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedLength > 0) {
|
||||
editor.onChange = (...args) => {
|
||||
const isSelectionChange = editor.operations.every((arg) => arg.type === 'set_selection');
|
||||
|
||||
if (isSelectionChange) {
|
||||
selectedBlocks.clear();
|
||||
}
|
||||
|
||||
onChange(...args);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
} else {
|
||||
editor.onChange = onChange;
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.onChange = onChange;
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
}, [editor, selectedBlocks, selectedLength]);
|
||||
|
||||
const decorate = useCallback(
|
||||
([, path]: NodeEntry): BaseRange[] => {
|
||||
const highlightRanges: (Range & {
|
||||
class_name: string;
|
||||
})[] = [];
|
||||
|
||||
ranges.forEach((state) => {
|
||||
const intersection = Range.intersection(state.range, Editor.range(editor, path));
|
||||
|
||||
if (intersection) {
|
||||
highlightRanges.push({
|
||||
...intersection,
|
||||
class_name: state.class_name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return highlightRanges;
|
||||
},
|
||||
[editor, ranges]
|
||||
[editor]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedBlocks,
|
||||
decorate,
|
||||
decorateState,
|
||||
};
|
||||
}
|
||||
|
||||
export const EditorSelectedBlockContext = createContext<Set<string>>(new Set());
|
||||
|
||||
export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider;
|
||||
|
||||
export const DecorateStateContext = createContext<
|
||||
Set<{
|
||||
range: BaseRange;
|
||||
class_name: string;
|
||||
}>
|
||||
>(new Set());
|
||||
|
||||
export const DecorateStateProvider = DecorateStateContext.Provider;
|
||||
|
@ -1,27 +1,39 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import {
|
||||
DecorateStateProvider,
|
||||
EditorSelectedBlockProvider,
|
||||
useDecorateCodeHighlight,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
useInlineKeyDown,
|
||||
} from '$app/components/editor/components/editor/Editor.hooks';
|
||||
import { Slate } from 'slate-react';
|
||||
import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable';
|
||||
import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar';
|
||||
import { useShortcuts } from '$app/components/editor/components/editor/shortcuts';
|
||||
import { useShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcuts';
|
||||
import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions';
|
||||
import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel';
|
||||
import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel';
|
||||
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import * as Y from 'yjs';
|
||||
import { NodeEntry } from 'slate';
|
||||
import {
|
||||
DecorateStateProvider,
|
||||
EditorSelectedBlockProvider,
|
||||
useInitialEditorState,
|
||||
SlashStateProvider,
|
||||
EditorInlineBlockStateProvider,
|
||||
} from '$app/components/editor/stores';
|
||||
import CommandPanel from '../tools/command_panel/CommandPanel';
|
||||
|
||||
function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
|
||||
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
|
||||
const decorateCodeHighlight = useDecorateCodeHighlight(editor);
|
||||
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
|
||||
const { selectedBlocks, decorate: decorateCustomRange, decorateState } = useEditorState(editor);
|
||||
const withInlineKeyDown = useInlineKeyDown(editor);
|
||||
const {
|
||||
selectedBlocks,
|
||||
decorate: decorateCustomRange,
|
||||
decorateState,
|
||||
slashState,
|
||||
inlineBlockState,
|
||||
} = useInitialEditorState(editor);
|
||||
|
||||
const decorate = useCallback(
|
||||
(entry: NodeEntry) => {
|
||||
@ -33,6 +45,14 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
|
||||
[decorateCodeHighlight, decorateCustomRange]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
withInlineKeyDown(event);
|
||||
onShortcutsKeyDown(event);
|
||||
},
|
||||
[onShortcutsKeyDown, withInlineKeyDown]
|
||||
);
|
||||
|
||||
if (editor.sharedRoot.length === 0) {
|
||||
return <CircularProgress className='m-auto' />;
|
||||
}
|
||||
@ -40,20 +60,24 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
|
||||
return (
|
||||
<EditorSelectedBlockProvider value={selectedBlocks}>
|
||||
<DecorateStateProvider value={decorateState}>
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<SelectionToolbar />
|
||||
<BlockActionsToolbar />
|
||||
<CustomEditable
|
||||
{...props}
|
||||
onDOMBeforeInput={onDOMBeforeInput}
|
||||
onKeyDown={onShortcutsKeyDown}
|
||||
decorate={decorate}
|
||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||
/>
|
||||
<SlashCommandPanel />
|
||||
<MentionPanel />
|
||||
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
||||
</Slate>
|
||||
<EditorInlineBlockStateProvider value={inlineBlockState}>
|
||||
<SlashStateProvider value={slashState}>
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<BlockActionsToolbar />
|
||||
<SelectionToolbar />
|
||||
|
||||
<CustomEditable
|
||||
{...props}
|
||||
onDOMBeforeInput={onDOMBeforeInput}
|
||||
onKeyDown={onKeyDown}
|
||||
decorate={decorate}
|
||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||
/>
|
||||
<CommandPanel />
|
||||
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
||||
</Slate>
|
||||
</SlashStateProvider>
|
||||
</EditorInlineBlockStateProvider>
|
||||
</DecorateStateProvider>
|
||||
</EditorSelectedBlockProvider>
|
||||
);
|
||||
|
@ -1,31 +1,30 @@
|
||||
import { Element } from 'slate';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { useSelected, useSlateStatic } from 'slate-react';
|
||||
import { useSelected } from 'slate-react';
|
||||
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected';
|
||||
|
||||
export function useElementState(element: Element) {
|
||||
const blockId = element.blockId;
|
||||
const editor = useSlateStatic();
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
const selected = useSelected();
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockId) return;
|
||||
if (selected && !editor.isSelectable(element)) {
|
||||
selectedBlockContext.add(blockId);
|
||||
} else {
|
||||
|
||||
if (!selected) {
|
||||
selectedBlockContext.delete(blockId);
|
||||
}
|
||||
}, [blockId, editor, element, selected, selectedBlockContext]);
|
||||
}, [blockId, selected, selectedBlockContext]);
|
||||
|
||||
const selectedBlockIds = useSnapshot(selectedBlockContext);
|
||||
const isSelected = useMemo(() => {
|
||||
const blockSelected = useMemo(() => {
|
||||
if (!blockId) return false;
|
||||
return selectedBlockIds.has(blockId);
|
||||
}, [blockId, selectedBlockIds]);
|
||||
|
||||
return {
|
||||
isSelected,
|
||||
blockSelected,
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
||||
import { RenderElementProps } from 'slate-react';
|
||||
import { RenderElementProps, useSlateStatic } from 'slate-react';
|
||||
import {
|
||||
BlockData,
|
||||
EditorElementProps,
|
||||
@ -73,7 +73,9 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
}
|
||||
}, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>;
|
||||
|
||||
const { isSelected } = useElementState(node);
|
||||
const editor = useSlateStatic();
|
||||
const { blockSelected } = useElementState(node);
|
||||
const isEmbed = editor.isEmbed(node);
|
||||
|
||||
const className = useMemo(() => {
|
||||
const align =
|
||||
@ -83,10 +85,10 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
}
|
||||
)?.align || 'left';
|
||||
|
||||
return `block-element flex rounded ${isSelected ? 'bg-content-blue-100' : ''} ${
|
||||
align ? `block-align-${align}` : ''
|
||||
return `block-element flex rounded ${align ? `block-align-${align}` : ''} ${
|
||||
blockSelected && !isEmbed ? 'bg-content-blue-100' : ''
|
||||
}`;
|
||||
}, [isSelected, node.data]);
|
||||
}, [node.data, blockSelected, isEmbed]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const data = (node.data as BlockData) || {};
|
||||
@ -99,9 +101,9 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
|
||||
if (InlineComponent) {
|
||||
return (
|
||||
<span {...attributes}>
|
||||
<InlineComponent node={node}>{children}</InlineComponent>
|
||||
</span>
|
||||
<InlineComponent {...attributes} node={node}>
|
||||
{children}
|
||||
</InlineComponent>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { RenderLeafProps } from 'slate-react';
|
||||
import { Link } from '$app/components/editor/components/marks';
|
||||
import { Link } from '$app/components/editor/components/inline_nodes/link';
|
||||
|
||||
export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
|
||||
let newChildren = children;
|
||||
@ -9,7 +9,12 @@ export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
|
||||
|
||||
if (leaf.code) {
|
||||
newChildren = (
|
||||
<code className={'bg-gray-300 bg-opacity-50 text-xs font-normal tracking-wider text-[#EB5757]'}>
|
||||
<code
|
||||
style={{
|
||||
fontSize: '0.85em',
|
||||
}}
|
||||
className={'bg-fill-list-active bg-opacity-50 font-normal tracking-wider text-[#EB5757]'}
|
||||
>
|
||||
{newChildren}
|
||||
</code>
|
||||
);
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './CollaborativeEditor';
|
||||
export * from './Editor';
|
||||
export { useDecorateCodeHighlight } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
|
@ -1,97 +0,0 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export enum EditorCommand {
|
||||
Mention = '@',
|
||||
SlashCommand = '/',
|
||||
}
|
||||
|
||||
// pop mention panel when @ is typed
|
||||
// pop slash command panel when / is typed
|
||||
const commands = [EditorCommand.Mention, EditorCommand.SlashCommand] as string[];
|
||||
|
||||
export const commandPanelClsSelector: Record<string, string> = {
|
||||
[EditorCommand.Mention]: '.mention-panel',
|
||||
[EditorCommand.SlashCommand]: '.slash-command-panel',
|
||||
};
|
||||
|
||||
export const commandPanelShowProperty = 'is-show';
|
||||
|
||||
export function withCommandShortcuts(editor: ReactEditor) {
|
||||
const { insertText, deleteBackward } = editor;
|
||||
|
||||
editor.insertText = (text) => {
|
||||
if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
const endOfPanelChar = commands.find((char) => {
|
||||
return text.endsWith(char);
|
||||
});
|
||||
|
||||
if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) {
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const { anchor } = selection;
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1);
|
||||
// show the panel when insert char at after space or at start of element
|
||||
const showPanel = !beforeText || beforeText.endsWith(' ');
|
||||
|
||||
if (showPanel) {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
if (commands.includes(endOfPanelChar)) {
|
||||
const selector = commandPanelClsSelector[endOfPanelChar] || '';
|
||||
|
||||
slateDom.parentElement?.querySelector(selector)?.classList.add(commandPanelShowProperty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insertText(text);
|
||||
};
|
||||
|
||||
editor.deleteBackward = (...args) => {
|
||||
if (CustomEditor.isCodeBlock(editor)) {
|
||||
deleteBackward(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { anchor } = selection;
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) });
|
||||
|
||||
// if delete backward at start of panel char, and then it will be deleted, so we should close the panel if it is open
|
||||
if (commands.includes(beforeText)) {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
const selector = commandPanelClsSelector[beforeText] || '';
|
||||
|
||||
slateDom.parentElement?.querySelector(selector)?.classList.remove(commandPanelShowProperty);
|
||||
}
|
||||
|
||||
// if delete backward at start of paragraph, and then it will be deleted, so we should close the panel if it is open
|
||||
if (CustomEditor.focusAtStartOfBlock(editor)) {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
commands.forEach((char) => {
|
||||
const selector = commandPanelClsSelector[char] || '';
|
||||
|
||||
slateDom.parentElement?.querySelector(selector)?.classList.remove(commandPanelShowProperty);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteBackward(...args);
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { withMarkdownShortcuts } from '$app/components/editor/components/editor/shortcuts/withMarkdownShortcuts';
|
||||
import { withCommandShortcuts } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts';
|
||||
|
||||
export function withShortcuts(editor: ReactEditor) {
|
||||
return withMarkdownShortcuts(withCommandShortcuts(editor));
|
||||
}
|
@ -37,26 +37,36 @@ function FormulaEditPopover({
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<div className='flex p-2 '>
|
||||
<div className='flex gap-1 p-3'>
|
||||
<TextField
|
||||
variant={'standard'}
|
||||
size={'small'}
|
||||
autoFocus={true}
|
||||
value={text}
|
||||
spellCheck={false}
|
||||
placeholder={'E = mc^2'}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth={true}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onDone(text);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={'ml-2'}>
|
||||
<Button size={'small'} variant={'text'} onClick={() => onDone(text)}>
|
||||
{t('button.done')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size={'small'} variant={'text'} onClick={() => onDone(text)}>
|
||||
{t('button.done')}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
|
@ -4,7 +4,10 @@ import KatexMath from '$app/components/_shared/katex_math/KatexMath';
|
||||
function FormulaLeaf({ formula, children }: { formula: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className={'relative'}>
|
||||
<KatexMath latex={formula || ''} isInline />
|
||||
<span className={'select-none'} contentEditable={false}>
|
||||
<KatexMath latex={formula || ''} isInline />
|
||||
</span>
|
||||
|
||||
<span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
@ -1,56 +1,46 @@
|
||||
import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect, useState } from 'react';
|
||||
import React, { forwardRef, memo, useCallback, MouseEvent, useRef } from 'react';
|
||||
import { ReactEditor, useSelected, useSlate } from 'slate-react';
|
||||
import { Transforms, Range, Editor } from 'slate';
|
||||
import { Transforms } from 'slate';
|
||||
import { EditorElementProps, FormulaNode } from '$app/application/document/document.types';
|
||||
import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf';
|
||||
import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover';
|
||||
import { getNodePath, moveCursorToNodeEnd } from '$app/components/editor/components/editor/utils';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useEditorInlineBlockState } from '$app/components/editor/stores';
|
||||
import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix';
|
||||
|
||||
export const InlineFormula = memo(
|
||||
forwardRef<HTMLSpanElement, EditorElementProps<FormulaNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const editor = useSlate();
|
||||
const formula = node.data;
|
||||
const selected = useSelected();
|
||||
|
||||
const { popoverOpen = false, setRange, openPopover, closePopover } = useEditorInlineBlockState('formula');
|
||||
const anchor = useRef<HTMLSpanElement | null>(null);
|
||||
const [openEditPopover, setOpenEditPopover] = useState<boolean>(false);
|
||||
const selected = useSelected();
|
||||
const open = popoverOpen && selected;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLSpanElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const path = getNodePath(editor, target);
|
||||
|
||||
setOpenEditPopover(true);
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.select(editor, path);
|
||||
if (editor.selection) {
|
||||
setRange(editor.selection);
|
||||
openPopover();
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
[editor, openPopover, setRange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const selection = editor.selection;
|
||||
|
||||
if (selected && selection) {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const nodeRange = Editor.range(editor, path);
|
||||
|
||||
if (Range.includes(nodeRange, selection.anchor) && Range.includes(nodeRange, selection.focus)) {
|
||||
setOpenEditPopover(true);
|
||||
}
|
||||
} else {
|
||||
setOpenEditPopover(false);
|
||||
}
|
||||
}, [editor, node, selected]);
|
||||
|
||||
const handleEditPopoverClose = useCallback(() => {
|
||||
setOpenEditPopover(false);
|
||||
closePopover();
|
||||
if (anchor.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveCursorToNodeEnd(editor, anchor.current);
|
||||
}, [editor]);
|
||||
}, [closePopover, editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -69,16 +59,23 @@ export const InlineFormula = memo(
|
||||
contentEditable={false}
|
||||
onDoubleClick={handleClick}
|
||||
onClick={handleClick}
|
||||
className={`relative rounded px-1 py-0.5 text-xs ${selected ? 'bg-fill-list-active' : ''}`}
|
||||
data-playwright-selected={selected}
|
||||
className={`${attributes.className ?? ''} formula-inline relative rounded px-1 py-0.5 ${
|
||||
selected ? 'selected' : ''
|
||||
}`}
|
||||
>
|
||||
<InlineChromiumBugfix />
|
||||
<FormulaLeaf formula={formula}>{children}</FormulaLeaf>
|
||||
<InlineChromiumBugfix />
|
||||
</span>
|
||||
{openEditPopover && (
|
||||
{open && (
|
||||
<FormulaEditPopover
|
||||
defaultText={formula}
|
||||
onDone={(newFormula) => {
|
||||
if (anchor.current === null || newFormula === formula) return;
|
||||
if (anchor.current === null || newFormula === formula) {
|
||||
handleEditPopoverClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getNodePath(editor, anchor.current);
|
||||
|
||||
// select the node before updating the formula
|
||||
@ -87,7 +84,7 @@ export const InlineFormula = memo(
|
||||
const point = editor.before(path);
|
||||
|
||||
CustomEditor.deleteFormula(editor);
|
||||
setOpenEditPopover(false);
|
||||
closePopover();
|
||||
if (point) {
|
||||
ReactEditor.focus(editor);
|
||||
editor.select(point);
|
||||
@ -100,7 +97,7 @@ export const InlineFormula = memo(
|
||||
}
|
||||
}}
|
||||
anchorEl={anchor.current}
|
||||
open={openEditPopover}
|
||||
open={open}
|
||||
onClose={handleEditPopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { getNodePath } from '$app/components/editor/components/editor/utils';
|
||||
import { Transforms, Text } from 'slate';
|
||||
import { useDecorateDispatch } from '$app/components/editor/stores';
|
||||
|
||||
export const Link = memo(({ children }: { leaf: Text; children: React.ReactNode }) => {
|
||||
const { add: addDecorate } = useDecorateDispatch();
|
||||
|
||||
const editor = useSlate();
|
||||
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (ref.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getNodePath(editor, ref.current);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.select(editor, path);
|
||||
|
||||
if (!editor.selection) return;
|
||||
addDecorate({
|
||||
range: editor.selection,
|
||||
class_name: 'bg-content-blue-100 rounded',
|
||||
type: 'link',
|
||||
});
|
||||
},
|
||||
[addDecorate, editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseDown={handleClick}
|
||||
className={`cursor-pointer rounded px-1 py-0.5 text-fill-default underline`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
});
|
@ -0,0 +1,169 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { addMark, removeMark } from 'slate';
|
||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||
import { open as openWindow } from '@tauri-apps/api/shell';
|
||||
import { notify } from '$app/components/editor/components/tools/notify';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactComponent as RemoveSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
|
||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||
import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
||||
|
||||
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { t } = useTranslation();
|
||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Href);
|
||||
|
||||
const [focusMenu, setFocusMenu] = useState<boolean>(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLElement>(null);
|
||||
const [link, setLink] = useState<string>(defaultHref);
|
||||
|
||||
const setNodeMark = useCallback(() => {
|
||||
if (link === '') {
|
||||
removeMark(editor, EditorMarkFormat.Href);
|
||||
} else {
|
||||
addMark(editor, EditorMarkFormat.Href, link);
|
||||
}
|
||||
}, [editor, link]);
|
||||
|
||||
const removeNodeMark = useCallback(() => {
|
||||
onClose();
|
||||
editor.removeMark(EditorMarkFormat.Href);
|
||||
}, [editor, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
|
||||
if (!input) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (pattern.test(link)) {
|
||||
onClose();
|
||||
setNodeMark();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
setFocusMenu(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
notify.clear();
|
||||
notify.info(`Press Tab to focus on the menu`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
input.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [link, onClose, setNodeMark]);
|
||||
|
||||
const onConfirm = useCallback(
|
||||
(key: string) => {
|
||||
if (key === 'open') {
|
||||
const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:'];
|
||||
|
||||
if (linkPrefix.some((prefix) => link.startsWith(prefix))) {
|
||||
void openWindow(link);
|
||||
} else {
|
||||
void openWindow('https://' + link);
|
||||
}
|
||||
} else if (key === 'copy') {
|
||||
void navigator.clipboard.writeText(link);
|
||||
notify.success(t('message.copy.success'));
|
||||
} else if (key === 'remove') {
|
||||
removeNodeMark();
|
||||
}
|
||||
},
|
||||
[link, removeNodeMark, t]
|
||||
);
|
||||
|
||||
const renderOption = useCallback((icon: React.ReactNode, label: string) => {
|
||||
return (
|
||||
<div key={label} className={'flex items-center gap-2'}>
|
||||
{icon}
|
||||
<div className={'flex-1'}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const editOptions: KeyboardNavigationOption[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'open',
|
||||
disabled: !pattern.test(link),
|
||||
content: renderOption(<LinkSvg className={'h-4 w-4'} />, t('editor.openLink')),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
content: renderOption(<CopySvg className={'h-4 w-4'} />, t('editor.copyLink')),
|
||||
},
|
||||
{
|
||||
key: 'remove',
|
||||
content: renderOption(<RemoveSvg className={'h-4 w-4'} />, t('editor.removeLink')),
|
||||
},
|
||||
];
|
||||
}, [link, renderOption, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isActivated && (
|
||||
<Typography className={'w-full justify-start pb-2 font-medium'}>{t('editor.addYourLink')}</Typography>
|
||||
)}
|
||||
<LinkEditInput link={link} setLink={setLink} inputRef={inputRef} />
|
||||
|
||||
<div ref={scrollRef} className={'mt-1 flex w-full flex-col items-start'}>
|
||||
{isActivated && (
|
||||
<KeyboardNavigation
|
||||
disableFocus={!focusMenu}
|
||||
scrollRef={scrollRef}
|
||||
options={editOptions}
|
||||
onConfirm={onConfirm}
|
||||
onFocus={() => {
|
||||
setFocusMenu(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusMenu(false);
|
||||
}}
|
||||
onEscape={onClose}
|
||||
disableSelect={!focusMenu}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isHotkey('Tab', e)) {
|
||||
e.preventDefault();
|
||||
setFocusMenu(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkEditContent;
|
@ -0,0 +1,48 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
||||
|
||||
function LinkEditInput({
|
||||
link,
|
||||
setLink,
|
||||
inputRef,
|
||||
}: {
|
||||
link: string;
|
||||
setLink: (link: string) => void;
|
||||
inputRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pattern.test(link)) {
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(t('editor.incorrectLink'));
|
||||
}, [link, t]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<TextField
|
||||
variant={'outlined'}
|
||||
size={'small'}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
autoFocus={true}
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
spellCheck={false}
|
||||
inputRef={inputRef}
|
||||
className={'my-1 p-0'}
|
||||
placeholder={'https://example.com'}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkEditInput;
|
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
|
||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import LinkEditContent from '$app/components/editor/components/inline_nodes/link/LinkEditContent';
|
||||
|
||||
const initialAnchorOrigin: PopoverOrigin = {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
};
|
||||
|
||||
const initialTransformOrigin: PopoverOrigin = {
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
};
|
||||
|
||||
export function LinkEditPopover({
|
||||
defaultHref,
|
||||
open,
|
||||
onClose,
|
||||
anchorPosition,
|
||||
anchorReference,
|
||||
}: {
|
||||
defaultHref: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorPosition?: { top: number; left: number; height: number };
|
||||
anchorReference?: 'anchorPosition' | 'anchorEl';
|
||||
}) {
|
||||
const {
|
||||
paperHeight,
|
||||
anchorPosition: newAnchorPosition,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
} = usePopoverAutoPosition({
|
||||
anchorPosition,
|
||||
open,
|
||||
initialAnchorOrigin,
|
||||
initialTransformOrigin,
|
||||
initialPaperWidth: 340,
|
||||
initialPaperHeight: 180,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
{...PopoverCommonProps}
|
||||
open={open}
|
||||
anchorPosition={newAnchorPosition}
|
||||
anchorReference={anchorReference}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
}}
|
||||
transformOrigin={transformOrigin}
|
||||
anchorOrigin={anchorOrigin}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: paperHeight,
|
||||
}}
|
||||
className='flex flex-col p-4'
|
||||
>
|
||||
<LinkEditContent defaultHref={defaultHref} onClose={onClose} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -2,13 +2,16 @@ import React, { forwardRef, memo } from 'react';
|
||||
import { EditorElementProps, MentionNode } from '$app/application/document/document.types';
|
||||
|
||||
import MentionLeaf from '$app/components/editor/components/inline_nodes/mention/MentionLeaf';
|
||||
import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix';
|
||||
|
||||
export const Mention = memo(
|
||||
forwardRef<HTMLSpanElement, EditorElementProps<MentionNode>>(({ node, children, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<span {...attributes} contentEditable={false} ref={ref}>
|
||||
<InlineChromiumBugfix />
|
||||
<MentionLeaf mention={node.data}>{children}</MentionLeaf>
|
||||
<InlineChromiumBugfix />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { pageTypeMap } from '$app_reducers/pages/slice';
|
||||
import { getPage } from '$app/application/folder/page.service';
|
||||
import { useSelected } from 'slate-react';
|
||||
|
||||
export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState<MentionPage | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const selected = useSelected();
|
||||
const loadPage = useCallback(async () => {
|
||||
if (!mention.page) return;
|
||||
const page = await getPage(mention.page);
|
||||
@ -32,13 +34,17 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
|
||||
<span className={'relative'}>
|
||||
{page && (
|
||||
<span
|
||||
className={'relative inline-flex cursor-pointer items-center hover:bg-content-blue-100'}
|
||||
className={'relative mx-1 inline-flex cursor-pointer items-center hover:rounded hover:bg-content-blue-100'}
|
||||
onClick={openPage}
|
||||
style={{
|
||||
backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span className={'text-sx absolute left-0'}>{page.icon?.value || <DocumentSvg />}</span>
|
||||
<span className={'ml-6 underline'}>{page.name || t('document.title.placeholder')}</span>
|
||||
<span className={'text-sx absolute left-0.5'}>{page.icon?.value || <DocumentSvg />}</span>
|
||||
<span className={'ml-6 mr-0.5 underline'}>{page.name || t('document.title.placeholder')}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={'invisible'}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
@ -1 +0,0 @@
|
||||
export * from './link';
|
@ -1,106 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ReactEditor, useSelected, useSlate } from 'slate-react';
|
||||
import { getNodePath, moveCursorToNodeEnd, moveCursorToPoint } from '$app/components/editor/components/editor/utils';
|
||||
import { BasePoint, Transforms, Text, Range, Editor } from 'slate';
|
||||
import { LinkEditPopover } from '$app/components/editor/components/marks/link/LinkEditPopover';
|
||||
|
||||
export const Link = memo(({ leaf, children }: { leaf: Text; children: React.ReactNode }) => {
|
||||
const nodeSelected = useSelected();
|
||||
|
||||
const editor = useSlate();
|
||||
|
||||
const [selected, setSelected] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const [openEditPopover, setOpenEditPopover] = useState<boolean>(false);
|
||||
|
||||
const getSelected = useCallback(
|
||||
(el: HTMLSpanElement, selection: Range) => {
|
||||
const entry = Editor.node(editor, selection);
|
||||
const [node, path] = entry;
|
||||
const dom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
if (!dom.contains(el)) return false;
|
||||
|
||||
const offset = Editor.string(editor, path).length;
|
||||
const range = {
|
||||
anchor: {
|
||||
path,
|
||||
offset: 0,
|
||||
},
|
||||
focus: {
|
||||
path,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
|
||||
return Range.equals(range, selection);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const selection = editor.selection;
|
||||
|
||||
if (!nodeSelected || !selection) {
|
||||
setOpenEditPopover(false);
|
||||
setSelected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = getSelected(ref.current, selection);
|
||||
|
||||
setOpenEditPopover(selected);
|
||||
setSelected(selected);
|
||||
}, [getSelected, editor, nodeSelected]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (ref.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getNodePath(editor, ref.current);
|
||||
|
||||
setOpenEditPopover(true);
|
||||
setSelected(true);
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.select(editor, path);
|
||||
}, [editor]);
|
||||
|
||||
const handleEditPopoverClose = useCallback(
|
||||
(at?: BasePoint) => {
|
||||
setOpenEditPopover(false);
|
||||
setSelected(false);
|
||||
if (ref.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!at) {
|
||||
moveCursorToNodeEnd(editor, ref.current);
|
||||
} else {
|
||||
moveCursorToPoint(editor, at);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`rounded px-1 py-0.5 text-fill-default underline ${selected ? 'bg-content-blue-50' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{openEditPopover && (
|
||||
<LinkEditPopover
|
||||
open={openEditPopover}
|
||||
anchorEl={ref.current}
|
||||
onClose={handleEditPopoverClose}
|
||||
defaultHref={leaf.href || ''}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,141 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import Button from '@mui/material/Button';
|
||||
import { getNodePath } from '$app/components/editor/components/editor/utils';
|
||||
import { addMark, BasePoint, Editor, Transforms, removeMark } from 'slate';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { ReactComponent as RemoveSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
|
||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||
import { open as openWindow } from '@tauri-apps/api/shell';
|
||||
import { OutlinedInput } from '@mui/material';
|
||||
import { notify } from '$app/components/editor/components/tools/notify';
|
||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||
|
||||
const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
||||
|
||||
export function LinkEditPopover({
|
||||
defaultHref,
|
||||
open,
|
||||
anchorEl,
|
||||
onClose,
|
||||
}: {
|
||||
defaultHref: string;
|
||||
open: boolean;
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: (at?: BasePoint) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlate();
|
||||
const [link, setLink] = useState<string>(defaultHref);
|
||||
|
||||
const setNodeMark = useCallback(() => {
|
||||
if (!anchorEl) return;
|
||||
const path = getNodePath(editor, anchorEl);
|
||||
|
||||
// select the node before updating the formula
|
||||
Transforms.select(editor, path);
|
||||
|
||||
if (link === '') {
|
||||
removeMark(editor, EditorMarkFormat.Href);
|
||||
} else {
|
||||
addMark(editor, EditorMarkFormat.Href, link);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [editor, anchorEl, link, onClose]);
|
||||
|
||||
const removeNodeMark = useCallback(() => {
|
||||
if (!anchorEl) return;
|
||||
const path = getNodePath(editor, anchorEl);
|
||||
const beforePath = Editor.before(editor, path);
|
||||
const beforePathEnd = beforePath ? Editor.end(editor, beforePath) : undefined;
|
||||
|
||||
// select the node before updating the formula
|
||||
Transforms.select(editor, path);
|
||||
|
||||
editor.removeMark(EditorMarkFormat.Href);
|
||||
|
||||
onClose(beforePathEnd);
|
||||
}, [editor, anchorEl, onClose]);
|
||||
|
||||
const linkActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <LinkSvg />,
|
||||
tooltip: t('editor.openLink'),
|
||||
onClick: () => {
|
||||
void openWindow(link);
|
||||
},
|
||||
disabled: !pattern.test(link),
|
||||
},
|
||||
{
|
||||
icon: <CopySvg />,
|
||||
tooltip: t('editor.copyLink'),
|
||||
onClick: async () => {
|
||||
await navigator.clipboard.writeText(link);
|
||||
notify.success(t('message.copy.success'));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <RemoveSvg />,
|
||||
tooltip: t('editor.removeLink'),
|
||||
onClick: removeNodeMark,
|
||||
},
|
||||
],
|
||||
[link, t, removeNodeMark]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
{...PopoverCommonProps}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col p-2'>
|
||||
<OutlinedInput
|
||||
size={'small'}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && link) {
|
||||
setNodeMark();
|
||||
}
|
||||
}}
|
||||
className={'my-1 p-0'}
|
||||
value={link}
|
||||
placeholder={'https://example.com'}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
fullWidth={true}
|
||||
/>
|
||||
<div className={'mt-1 flex w-full flex-col items-start'}>
|
||||
{linkActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
disabled={action.disabled}
|
||||
className={'w-full justify-start'}
|
||||
size={'small'}
|
||||
color={'inherit'}
|
||||
startIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.tooltip}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
|
||||
|
||||
export function BgColorPicker(props: ColorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <ColorPicker {...props} label={t('editor.backgroundColor')} />;
|
||||
}
|
@ -1,155 +1,173 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomColorPicker } from '$app/components/editor/components/tools/_shared/CustomColorPicker';
|
||||
import React, { useCallback, useRef, useMemo } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { IconButton, MenuItem, MenuList } from '@mui/material';
|
||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
import { PopoverNoBackdropProps } from '$app/components/editor/components/tools/popover';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { ThemeMode } from '$app_reducers/current-user/slice';
|
||||
import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TitleOutlined } from '@mui/icons-material';
|
||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||
|
||||
export interface ColorPickerProps {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
label?: string;
|
||||
color?: string;
|
||||
onChange?: (color: string) => void;
|
||||
onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void;
|
||||
onEscape?: () => void;
|
||||
disableFocus?: boolean;
|
||||
}
|
||||
export function ColorPicker({ onFocus, onBlur, label, color = '', onChange }: ColorPickerProps) {
|
||||
export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
|
||||
const [selectedColor, setSelectedColor] = useState(color);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColor(color);
|
||||
}, [color]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
onChange?.(color);
|
||||
};
|
||||
const handleColorChange = useCallback(
|
||||
(key: string) => {
|
||||
const [format, , color = ''] = key.split('-');
|
||||
const formatKey = format === 'font' ? EditorMarkFormat.FontColor : EditorMarkFormat.BgColor;
|
||||
|
||||
const colors = useMemo(() => {
|
||||
return !isDark
|
||||
? [
|
||||
'#e8e0ff',
|
||||
'#ffd6e8',
|
||||
'#f5d0ff',
|
||||
'#e1fbff',
|
||||
'#ffebcc',
|
||||
'#fff7cc',
|
||||
'#e8ffcc',
|
||||
'#e8f4ff',
|
||||
'#fff2cd',
|
||||
'#d9d9d9',
|
||||
'#f0f0f0',
|
||||
]
|
||||
: [
|
||||
'#4D4078',
|
||||
'#7B2CBF',
|
||||
'#FFB800',
|
||||
'#00B800',
|
||||
'#00B8FF',
|
||||
'#007BFF',
|
||||
'#B800FF',
|
||||
'#FF00B8',
|
||||
'#FF0000',
|
||||
'#FF6C00',
|
||||
'#FFD800',
|
||||
];
|
||||
}, [isDark]);
|
||||
onChange?.(formatKey, color);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const [openCustomColorPicker, setOpenCustomColorPicker] = useState(false);
|
||||
const renderColorItem = useCallback(
|
||||
(name: string, color: string, backgroundColor?: string) => {
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
onClick={() => {
|
||||
handleColorChange(backgroundColor ? backgroundColor : color);
|
||||
}}
|
||||
className={'flex w-full cursor-pointer items-center justify-center gap-2'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: backgroundColor ?? 'transparent',
|
||||
color: color === '' ? 'var(--text-title)' : color,
|
||||
}}
|
||||
className={'flex h-5 w-5 items-center justify-center rounded border border-line-divider'}
|
||||
>
|
||||
<TitleOutlined className={'h-4 w-4'} />
|
||||
</div>
|
||||
<div className={'flex-1 text-xs text-text-title'}>{name}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[handleColorChange]
|
||||
);
|
||||
|
||||
const customItemRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (openCustomColorPicker) {
|
||||
onFocus?.();
|
||||
} else {
|
||||
onBlur?.();
|
||||
}
|
||||
}, [openCustomColorPicker, onFocus, onBlur]);
|
||||
const colors: KeyboardNavigationOption[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'font_color',
|
||||
content: (
|
||||
<Typography className={'px-3 pb-1 pt-3 text-text-caption'} variant='subtitle2'>
|
||||
{t('editor.textColor')}
|
||||
</Typography>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
key: 'font-default',
|
||||
content: renderColorItem(t('editor.fontColorDefault'), ''),
|
||||
},
|
||||
{
|
||||
key: `font-gray-rgb(120, 119, 116)`,
|
||||
content: renderColorItem(t('editor.fontColorGray'), 'rgb(120, 119, 116)'),
|
||||
},
|
||||
{
|
||||
key: 'font-brown-rgb(159, 107, 83)',
|
||||
content: renderColorItem(t('editor.fontColorBrown'), 'rgb(159, 107, 83)'),
|
||||
},
|
||||
{
|
||||
key: 'font-orange-rgb(217, 115, 13)',
|
||||
content: renderColorItem(t('editor.fontColorOrange'), 'rgb(217, 115, 13)'),
|
||||
},
|
||||
{
|
||||
key: 'font-yellow-rgb(203, 145, 47)',
|
||||
content: renderColorItem(t('editor.fontColorYellow'), 'rgb(203, 145, 47)'),
|
||||
},
|
||||
{
|
||||
key: 'font-green-rgb(68, 131, 97)',
|
||||
content: renderColorItem(t('editor.fontColorGreen'), 'rgb(68, 131, 97)'),
|
||||
},
|
||||
{
|
||||
key: 'font-blue-rgb(51, 126, 169)',
|
||||
content: renderColorItem(t('editor.fontColorBlue'), 'rgb(51, 126, 169)'),
|
||||
},
|
||||
{
|
||||
key: 'font-purple-rgb(144, 101, 176)',
|
||||
content: renderColorItem(t('editor.fontColorPurple'), 'rgb(144, 101, 176)'),
|
||||
},
|
||||
{
|
||||
key: 'font-pink-rgb(193, 76, 138)',
|
||||
content: renderColorItem(t('editor.fontColorPink'), 'rgb(193, 76, 138)'),
|
||||
},
|
||||
{
|
||||
key: 'font-red-rgb(212, 76, 71)',
|
||||
content: renderColorItem(t('editor.fontColorRed'), 'rgb(212, 76, 71)'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bg_color',
|
||||
content: (
|
||||
<Typography className={'px-3 pb-1 pt-3 text-text-caption'} variant='subtitle2'>
|
||||
{t('editor.backgroundColor')}
|
||||
</Typography>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
key: 'bg-default',
|
||||
content: renderColorItem(t('editor.backgroundColorDefault'), '', ''),
|
||||
},
|
||||
{
|
||||
key: `bg-gray-rgba(161,161,159,0.61)`,
|
||||
content: renderColorItem(t('editor.backgroundColorGray'), '', 'rgba(161,161,159,0.61)'),
|
||||
},
|
||||
{
|
||||
key: `bg-brown-rgba(178,93,37,0.65)`,
|
||||
content: renderColorItem(t('editor.backgroundColorBrown'), '', 'rgba(178,93,37,0.65)'),
|
||||
},
|
||||
{
|
||||
key: `bg-orange-rgba(248,156,71,0.65)`,
|
||||
content: renderColorItem(t('editor.backgroundColorOrange'), '', 'rgba(248,156,71,0.65)'),
|
||||
},
|
||||
{
|
||||
key: `bg-yellow-rgba(229,197,137,0.6)`,
|
||||
content: renderColorItem(t('editor.backgroundColorYellow'), '', 'rgba(229,197,137,0.6)'),
|
||||
},
|
||||
{
|
||||
key: `bg-green-rgba(124,189,111,0.65)`,
|
||||
content: renderColorItem(t('editor.backgroundColorGreen'), '', 'rgba(124,189,111,0.65)'),
|
||||
},
|
||||
{
|
||||
key: `bg-blue-rgba(100,174,199,0.71)`,
|
||||
content: renderColorItem(t('editor.backgroundColorBlue'), '', 'rgba(100,174,199,0.71)'),
|
||||
},
|
||||
{
|
||||
key: `bg-purple-rgba(182,114,234,0.63)`,
|
||||
content: renderColorItem(t('editor.backgroundColorPurple'), '', 'rgba(182,114,234,0.63)'),
|
||||
},
|
||||
{
|
||||
key: `bg-pink-rgba(238,142,179,0.6)`,
|
||||
content: renderColorItem(t('editor.backgroundColorPink'), '', 'rgba(238,142,179,0.6)'),
|
||||
},
|
||||
{
|
||||
key: `bg-red-rgba(238,88,98,0.64)`,
|
||||
content: renderColorItem(t('editor.backgroundColorRed'), '', 'rgba(238,88,98,0.64)'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [renderColorItem, t]);
|
||||
|
||||
return (
|
||||
<div className={'flex min-w-[150px] flex-col'}>
|
||||
<Typography className={'px-3 pt-3 text-text-caption'} variant='subtitle2'>
|
||||
{label}
|
||||
</Typography>
|
||||
<MenuList disabledItemsFocusable={true}>
|
||||
<MenuItem
|
||||
onMouseEnter={() => {
|
||||
setOpenCustomColorPicker(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setOpenCustomColorPicker(false);
|
||||
}}
|
||||
className={'mx-2 mb-2 flex px-2.5 py-1'}
|
||||
ref={customItemRef}
|
||||
>
|
||||
<div className={'flex-1 text-xs'}>{t('colors.custom')}</div>
|
||||
<MoreSvg className={'h-4 w-4'} />
|
||||
{openCustomColorPicker && (
|
||||
<CustomColorPicker
|
||||
anchorEl={customItemRef.current}
|
||||
open={openCustomColorPicker}
|
||||
onColorChange={handleColorChange}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
{...PopoverNoBackdropProps}
|
||||
onClose={() => {
|
||||
setOpenCustomColorPicker(false);
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
// prevent editor blur
|
||||
e.stopPropagation();
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className={'flex flex-grow flex-wrap items-center gap-1 px-3 py-0'}>
|
||||
<Tooltip title={t('colors.default')}>
|
||||
<IconButton
|
||||
className={'rounded-full'}
|
||||
onClick={() => {
|
||||
handleColorChange('');
|
||||
}}
|
||||
>
|
||||
<DeleteSvg />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{colors.map((c) => {
|
||||
return (
|
||||
<IconButton
|
||||
key={c}
|
||||
onClick={() => {
|
||||
handleColorChange(c);
|
||||
}}
|
||||
className={'flex h-6 w-6 cursor-pointer items-center justify-center rounded-full p-1'}
|
||||
style={{
|
||||
backgroundColor: c === selectedColor ? 'var(--content-blue-100)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={'h-full w-full rounded-full'}
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</MenuList>
|
||||
<div ref={ref} className={'flex h-full max-h-[360px] w-full flex-col overflow-y-auto'}>
|
||||
<KeyboardNavigation
|
||||
disableFocus={disableFocus}
|
||||
onPressLeft={onEscape}
|
||||
scrollRef={ref}
|
||||
options={colors}
|
||||
onConfirm={handleColorChange}
|
||||
onEscape={onEscape}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||
import { RGBColor, SketchPicker } from 'react-color';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Color, SketchPicker } from 'react-color';
|
||||
|
||||
import { Divider } from '@mui/material';
|
||||
|
||||
export function CustomColorPicker({
|
||||
@ -11,29 +10,18 @@ export function CustomColorPicker({
|
||||
}: {
|
||||
onColorChange?: (color: string) => void;
|
||||
} & PopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [color, setColor] = useState<RGBColor | undefined>();
|
||||
const [color, setColor] = useState<Color | undefined>();
|
||||
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<SketchPicker
|
||||
onChange={(color) => {
|
||||
setColor(color.rgb);
|
||||
onColorChange?.(color.hex);
|
||||
}}
|
||||
color={color}
|
||||
/>
|
||||
<Divider />
|
||||
<div className={'z-10 flex justify-end bg-bg-body px-2 py-2'}>
|
||||
<Button
|
||||
size={'small'}
|
||||
onClick={() => {
|
||||
onColorChange?.(`rgba(${color?.r}, ${color?.g}, ${color?.b}, ${color?.a})`);
|
||||
}}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{t('button.done')}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker, ColorPickerProps } from '$app/components/editor/components/tools/_shared/ColorPicker';
|
||||
|
||||
export function FontColorPicker(props: ColorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <ColorPicker {...props} label={t('editor.textColor')} />;
|
||||
}
|
@ -1,4 +1,2 @@
|
||||
export * from './CustomColorPicker';
|
||||
export * from './FontColorPicker';
|
||||
export * from './ColorPicker';
|
||||
export * from './BgColorPicker';
|
||||
|
@ -1,116 +1,54 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
|
||||
import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent';
|
||||
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Element } from 'slate';
|
||||
import { Element, Path } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
import { useSlashState } from '$app/components/editor/stores';
|
||||
|
||||
function AddBlockBelow({ node }: { node?: Element }) {
|
||||
const { t } = useTranslation();
|
||||
const [nodeEl, setNodeEl] = useState<HTMLElement | null>(null);
|
||||
const editor = useSlate();
|
||||
const openSlashCommandPanel = useMemo(() => !!nodeEl, [nodeEl]);
|
||||
|
||||
const handleSlashCommandPanelClose = useCallback(
|
||||
(deleteText?: boolean) => {
|
||||
if (!nodeEl) return;
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
if (deleteText) {
|
||||
CustomEditor.deleteAllText(editor, node);
|
||||
}
|
||||
|
||||
setNodeEl(null);
|
||||
},
|
||||
[editor, nodeEl]
|
||||
);
|
||||
const { setOpen: setSlashOpen } = useSlashState();
|
||||
|
||||
const handleAddBelow = () => {
|
||||
if (!node) return;
|
||||
ReactEditor.focus(editor);
|
||||
|
||||
const [textNode] = node.children as Element[];
|
||||
const hasTextNode = textNode && textNode.type === EditorNodeType.Text;
|
||||
|
||||
const nodePath = ReactEditor.findPath(editor, node);
|
||||
const textPath = ReactEditor.findPath(editor, textNode);
|
||||
const nextPath = Path.next(nodePath);
|
||||
|
||||
const focusPath = hasTextNode ? textPath : nodePath;
|
||||
editor.select(nodePath);
|
||||
|
||||
editor.select(focusPath);
|
||||
editor.collapse({
|
||||
edge: 'end',
|
||||
});
|
||||
if (editor.isSelectable(node)) {
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
const isEmptyNode = CustomEditor.isEmptyText(editor, node);
|
||||
|
||||
if (isEmptyNode) {
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
setNodeEl(nodeDom);
|
||||
return;
|
||||
// if the node is not a paragraph, or it is not empty, insert a new empty line
|
||||
if (node.type !== EditorNodeType.Paragraph || !isEmptyNode) {
|
||||
CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath);
|
||||
editor.select(nextPath);
|
||||
}
|
||||
|
||||
editor.insertBreak();
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.Paragraph,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
|
||||
if (block) {
|
||||
const [node] = block;
|
||||
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
setNodeEl(nodeDom);
|
||||
}
|
||||
setSlashOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const searchText = useMemo(() => {
|
||||
if (!nodeEl) return '';
|
||||
const node = ReactEditor.toSlateNode(editor, nodeEl) as Element;
|
||||
|
||||
if (!node) return '';
|
||||
|
||||
return CustomEditor.getNodeText(editor, node);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, nodeEl, editor.selection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t('blockActions.addBelowTooltip')}>
|
||||
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')}>
|
||||
<IconButton onClick={handleAddBelow} size={'small'}>
|
||||
<AddSvg />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{openSlashCommandPanel && (
|
||||
<Popover
|
||||
{...PopoverPreventBlurProps}
|
||||
anchorOrigin={{
|
||||
vertical: 30,
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
open={openSlashCommandPanel}
|
||||
anchorEl={nodeEl}
|
||||
onClose={() => handleSlashCommandPanelClose(false)}
|
||||
>
|
||||
<SlashCommandPanelContent searchText={searchText} closePanel={handleSlashCommandPanelClose} />
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,13 +2,27 @@ import React from 'react';
|
||||
|
||||
import { Element } from 'slate';
|
||||
import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow';
|
||||
import BlockMenu from '$app/components/editor/components/tools/block_actions/BlockMenu';
|
||||
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function BlockActions({
|
||||
node,
|
||||
onClickDrag,
|
||||
}: {
|
||||
node?: Element;
|
||||
onClickDrag: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export function BlockActions({ node, setMenuVisible }: { node?: Element; setMenuVisible: (visible: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<AddBlockBelow node={node} />
|
||||
<BlockMenu setMenuVisible={setMenuVisible} node={node} />
|
||||
<Tooltip disableInteractive={true} title={t('blockActions.openMenuTooltip')}>
|
||||
<IconButton onClick={onClickDrag} size={'small'}>
|
||||
<DragSvg />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils';
|
||||
import { Element, Editor } from 'slate';
|
||||
import { findEventRange, getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils';
|
||||
import { Element, Editor, Range } from 'slate';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
|
||||
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
export function useBlockActionsToolbar(ref: RefObject<HTMLDivElement>, contextMenuVisible: boolean) {
|
||||
const editor = useSlate();
|
||||
const [node, setNode] = useState<Element | null>(null);
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
|
||||
const recalculatePosition = useCallback(
|
||||
(blockElement: HTMLElement) => {
|
||||
const { top, left } = getBlockActionsPosition(editor, blockElement);
|
||||
|
||||
const slateEditorDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
if (!ref.current) return;
|
||||
|
||||
ref.current.style.top = `${top + slateEditorDom.offsetTop}px`;
|
||||
ref.current.style.left = `${left + slateEditorDom.offsetLeft - 64}px`;
|
||||
},
|
||||
[editor, ref]
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.pointerEvents = 'none';
|
||||
setNode(null);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
@ -21,7 +44,13 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = ReactEditor.findEventRange(editor, e);
|
||||
let range: Range | null = null;
|
||||
|
||||
try {
|
||||
range = ReactEditor.findEventRange(editor, e);
|
||||
} catch {
|
||||
range = findEventRange(editor, e);
|
||||
}
|
||||
|
||||
if (!range) return;
|
||||
const match = editor.above({
|
||||
@ -32,9 +61,7 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
el.style.opacity = '0';
|
||||
el.style.pointerEvents = 'none';
|
||||
setNode(null);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -44,48 +71,48 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
|
||||
const blockElement = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
if (!blockElement) return;
|
||||
|
||||
const { top, left } = getBlockActionsPosition(editor, blockElement);
|
||||
|
||||
const slateEditorDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
recalculatePosition(blockElement);
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
el.style.top = `${top + slateEditorDom.offsetTop}px`;
|
||||
el.style.left = `${left + slateEditorDom.offsetLeft - 64}px`;
|
||||
const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element;
|
||||
|
||||
setNode(slateNode);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (_e: MouseEvent) => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.pointerEvents = 'none';
|
||||
setNode(null);
|
||||
};
|
||||
|
||||
const dom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
if (!menuVisible) {
|
||||
if (!contextMenuVisible) {
|
||||
dom.addEventListener('mousemove', handleMouseMove);
|
||||
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
|
||||
} else {
|
||||
dom.removeEventListener('mousemove', handleMouseMove);
|
||||
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
|
||||
dom.parentElement?.addEventListener('mouseleave', close);
|
||||
}
|
||||
|
||||
return () => {
|
||||
dom.removeEventListener('mousemove', handleMouseMove);
|
||||
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
|
||||
dom.parentElement?.removeEventListener('mouseleave', close);
|
||||
};
|
||||
}, [editor, ref, menuVisible]);
|
||||
}, [close, editor, contextMenuVisible, ref, recalculatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
if (node) {
|
||||
const dom = ReactEditor.toDOMNode(editor, node);
|
||||
|
||||
if (dom.parentElement) {
|
||||
observer = new MutationObserver(close);
|
||||
|
||||
observer.observe(dom.parentElement, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [close, editor, node]);
|
||||
|
||||
return {
|
||||
setMenuVisible,
|
||||
node: node?.type === EditorNodeType.Page ? null : node,
|
||||
};
|
||||
}
|
||||
|
@ -1,33 +1,103 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useBlockActionsToolbar } from './BlockActionsToolbar.hooks';
|
||||
import BlockActions from '$app/components/editor/components/tools/block_actions/BlockActions';
|
||||
|
||||
import { getBlockCssProperty } from '$app/components/editor/components/tools/block_actions/utils';
|
||||
import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
|
||||
export function BlockActionsToolbar() {
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected';
|
||||
import withErrorBoundary from '$app/components/_shared/error_boundary/withError';
|
||||
|
||||
const Toolbar = () => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { node, setMenuVisible } = useBlockActionsToolbar(ref);
|
||||
|
||||
const [openContextMenu, setOpenContextMenu] = useState(false);
|
||||
const { node } = useBlockActionsToolbar(ref, openContextMenu);
|
||||
const cssProperty = node && getBlockCssProperty(node);
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
const popoverPropsRef = useRef<Partial<PopoverProps> | undefined>(undefined);
|
||||
const editor = useSlateStatic();
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
if (!node || !node.blockId) return;
|
||||
setOpenContextMenu(true);
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(node.blockId);
|
||||
}, [node, selectedBlockContext]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpenContextMenu(false);
|
||||
selectedBlockContext.clear();
|
||||
}, [selectedBlockContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
const nodeDom = ReactEditor.toDOMNode(editor, node);
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
popoverPropsRef.current = {
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
},
|
||||
anchorReference: 'anchorPosition',
|
||||
anchorPosition: {
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
},
|
||||
};
|
||||
|
||||
handleOpen();
|
||||
};
|
||||
|
||||
nodeDom.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
return () => {
|
||||
nodeDom.removeEventListener('contextmenu', onContextMenu);
|
||||
};
|
||||
}, [editor, handleOpen, node]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable={false}
|
||||
className={`block-actions ${cssProperty} absolute z-10 flex min-h-[26px] w-[64px] flex-grow transform items-center justify-end px-1 opacity-0 transition-opacity`}
|
||||
onMouseDown={(e) => {
|
||||
// prevent toolbar from taking focus away from editor
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Ensure the toolbar in middle */}
|
||||
<div className={`invisible`}>$</div>
|
||||
{<BlockActions setMenuVisible={setMenuVisible} node={node || undefined} />}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable={false}
|
||||
className={`block-actions absolute z-10 flex w-[64px] flex-grow transform items-center justify-end px-1 opacity-0 ${cssProperty}`}
|
||||
>
|
||||
{/* Ensure the toolbar in middle */}
|
||||
<div className={`invisible`}>$</div>
|
||||
{
|
||||
<BlockActions
|
||||
node={node || undefined}
|
||||
onClickDrag={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
popoverPropsRef.current = {
|
||||
transformOrigin: {
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
},
|
||||
anchorReference: 'anchorPosition',
|
||||
anchorPosition: {
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.left,
|
||||
},
|
||||
};
|
||||
|
||||
handleOpen();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{node && openContextMenu && (
|
||||
<BlockOperationMenu node={node} open={openContextMenu} onClose={handleClose} {...popoverPropsRef.current} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const BlockActionsToolbar = withErrorBoundary(Toolbar);
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { useCallback, KeyboardEvent } from 'react';
|
||||
|
||||
export function useBlockMenuKeyDown({ onClose }: { onClose: () => void }) {
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return {
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
|
||||
import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu';
|
||||
import { Element } from 'slate';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
|
||||
|
||||
function BlockMenu({ node, setMenuVisible }: { node?: Element; setMenuVisible: (visible: boolean) => void }) {
|
||||
const dragBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [selectedNode, setSelectedNode] = useState<Element>();
|
||||
const selectedBlockContext = useContext(EditorSelectedBlockContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (openMenu) {
|
||||
setMenuVisible(true);
|
||||
} else {
|
||||
setMenuVisible(false);
|
||||
}
|
||||
}, [openMenu, setMenuVisible]);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(true);
|
||||
if (!node || !node.blockId) return;
|
||||
setSelectedNode(node);
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(node.blockId);
|
||||
},
|
||||
[node, selectedBlockContext]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t('blockActions.openMenuTooltip')}>
|
||||
<IconButton onClick={handleClick} ref={dragBtnRef} size={'small'}>
|
||||
<DragSvg />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{openMenu && selectedNode && (
|
||||
<BlockOperationMenu
|
||||
onMouseMove={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
node={selectedNode}
|
||||
open={openMenu}
|
||||
anchorEl={dragBtnRef.current}
|
||||
onClose={() => {
|
||||
setOpenMenu(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenu;
|
@ -3,13 +3,29 @@ import Popover, { PopoverProps } from '@mui/material/Popover';
|
||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider } from '@mui/material';
|
||||
import { Divider } from '@mui/material';
|
||||
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||
import { Element } from 'slate';
|
||||
import { Element, Path } from 'slate';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useBlockMenuKeyDown } from '$app/components/editor/components/tools/block_actions/BlockMenu.hooks';
|
||||
import { Color } from './color';
|
||||
import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { Color } from '$app/components/editor/components/tools/block_actions/color';
|
||||
import { getModifier } from '$app/components/editor/plugins/shortcuts';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected';
|
||||
|
||||
export const canSetColorBlocks: EditorNodeType[] = [
|
||||
EditorNodeType.Paragraph,
|
||||
EditorNodeType.HeadingBlock,
|
||||
EditorNodeType.TodoListBlock,
|
||||
EditorNodeType.BulletedListBlock,
|
||||
EditorNodeType.NumberedListBlock,
|
||||
EditorNodeType.ToggleListBlock,
|
||||
EditorNodeType.QuoteBlock,
|
||||
];
|
||||
|
||||
export function BlockOperationMenu({
|
||||
node,
|
||||
@ -20,80 +36,170 @@ export function BlockOperationMenu({
|
||||
const editor = useSlateStatic();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const canSetColor = useMemo(() => {
|
||||
return canSetColorBlocks.includes(node.type as EditorNodeType);
|
||||
}, [node]);
|
||||
const selectedBlockContext = React.useContext(EditorSelectedBlockContext);
|
||||
const [openColorMenu, setOpenColorMenu] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const handleClose = useCallback(() => {
|
||||
props.onClose?.({}, 'backdropClick');
|
||||
ReactEditor.focus(editor);
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
try {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
|
||||
editor.select(path);
|
||||
if (editor.isSelectable(node)) {
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
editor.select(path);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
}, [editor, node, props]);
|
||||
|
||||
const { onKeyDown } = useBlockMenuKeyDown({
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const operationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <DeleteSvg />,
|
||||
text: t('button.delete'),
|
||||
onClick: () => {
|
||||
const onConfirm = useCallback(
|
||||
(optionKey: string) => {
|
||||
switch (optionKey) {
|
||||
case 'delete': {
|
||||
CustomEditor.deleteNode(editor, node);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'duplicate': {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const newNode = CustomEditor.duplicateNode(editor, node);
|
||||
|
||||
handleClose();
|
||||
|
||||
const newBlockId = newNode.blockId;
|
||||
|
||||
if (!newBlockId) return;
|
||||
requestAnimationFrame(() => {
|
||||
selectedBlockContext.clear();
|
||||
selectedBlockContext.add(newBlockId);
|
||||
const nextPath = Path.next(path);
|
||||
|
||||
editor.select(nextPath);
|
||||
editor.collapse({
|
||||
edge: 'start',
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case 'color': {
|
||||
setOpenColorMenu(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
},
|
||||
[editor, handleClose, node, selectedBlockContext]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const options: KeyboardNavigationOption[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: 'block-operation',
|
||||
children: [
|
||||
{
|
||||
key: 'delete',
|
||||
content: (
|
||||
<div className={'flex w-full items-center justify-between gap-2'}>
|
||||
<DeleteSvg className={'h-5 w-5'} />
|
||||
<div className={'flex-1'}>{t('button.delete')}</div>
|
||||
<div className={'text-right text-text-caption'}>{'Del'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
content: (
|
||||
<div className={'flex w-full items-center justify-between gap-2'}>
|
||||
<CopySvg className={'h-5 w-5'} />
|
||||
<div className={'flex-1'}>{t('button.duplicate')}</div>
|
||||
<div className={'text-right text-text-caption'}>{`${getModifier()} + D`}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <CopySvg />,
|
||||
text: t('button.duplicate'),
|
||||
onClick: () => {
|
||||
CustomEditor.duplicateNode(editor, node);
|
||||
handleClose();
|
||||
canSetColor && {
|
||||
key: 'color',
|
||||
content: <Divider />,
|
||||
children: [
|
||||
{
|
||||
key: 'color',
|
||||
content: (
|
||||
<Color
|
||||
node={
|
||||
node as {
|
||||
data?: {
|
||||
font_color?: string;
|
||||
bg_color?: string;
|
||||
};
|
||||
} & Element
|
||||
}
|
||||
onClosePicker={() => {
|
||||
setOpenColorMenu(false);
|
||||
}}
|
||||
openPicker={openColorMenu}
|
||||
onOpenPicker={() => setOpenColorMenu(true)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[editor, node, handleClose, t]
|
||||
].filter(Boolean),
|
||||
[node, canSetColor, openColorMenu, t]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isHotkey('mod+d', e)) {
|
||||
e.preventDefault();
|
||||
onConfirm('duplicate');
|
||||
}
|
||||
|
||||
if (isHotkey('del', e) || isHotkey('backspace', e)) {
|
||||
e.preventDefault();
|
||||
onConfirm('delete');
|
||||
}
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
{...PopoverCommonProps}
|
||||
disableAutoFocus={false}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className={'flex flex-col p-2'}>
|
||||
{operationOptions.map((option, index) => (
|
||||
<Button
|
||||
color={'inherit'}
|
||||
onClick={option.onClick}
|
||||
startIcon={option.icon}
|
||||
size={'small'}
|
||||
className={'w-full justify-start'}
|
||||
key={index}
|
||||
>
|
||||
{option.text}
|
||||
</Button>
|
||||
))}
|
||||
<div className={'max-h-[360px] w-full overflow-y-auto py-1'} ref={ref}>
|
||||
<KeyboardNavigation
|
||||
onKeyDown={handleKeyDown}
|
||||
onPressLeft={handleClose}
|
||||
onPressRight={(key) => {
|
||||
if (key === 'color') {
|
||||
onConfirm(key);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
scrollRef={ref}
|
||||
onEscape={handleClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
<Divider className={'my-1'} />
|
||||
<Color
|
||||
node={
|
||||
node as Element & {
|
||||
data?: {
|
||||
font_color?: string;
|
||||
bg_color?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
@ -1,17 +1,29 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Element } from 'slate';
|
||||
import { Button, Popover } from '@mui/material';
|
||||
import { Popover } from '@mui/material';
|
||||
import ColorLensOutlinedIcon from '@mui/icons-material/ColorLensOutlined';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BgColorPicker, FontColorPicker } from '$app/components/editor/components/tools/_shared';
|
||||
import { ColorPicker } from '$app/components/editor/components/tools/_shared';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { PopoverNoBackdropProps } from '$app/components/editor/components/tools/popover';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
|
||||
const initialOrigin: {
|
||||
transformOrigin?: PopoverOrigin;
|
||||
anchorOrigin?: PopoverOrigin;
|
||||
} = {
|
||||
anchorOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
};
|
||||
|
||||
export function Color({
|
||||
node,
|
||||
onClose,
|
||||
openPicker,
|
||||
onOpenPicker,
|
||||
onClosePicker,
|
||||
}: {
|
||||
node: Element & {
|
||||
data?: {
|
||||
@ -19,82 +31,56 @@ export function Color({
|
||||
bg_color?: string;
|
||||
};
|
||||
};
|
||||
onClose?: () => void;
|
||||
openPicker?: boolean;
|
||||
onOpenPicker?: () => void;
|
||||
onClosePicker?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editor = useSlateStatic();
|
||||
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const fontColor = useMemo(() => {
|
||||
return node.data?.font_color;
|
||||
}, [node]);
|
||||
|
||||
const bgColor = useMemo(() => {
|
||||
return node.data?.bg_color;
|
||||
}, [node]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onColorChange = useCallback(
|
||||
(format: 'font_color' | 'bg_color', color: string) => {
|
||||
CustomEditor.setBlockColor(editor, node, {
|
||||
[format]: color,
|
||||
});
|
||||
onClose?.();
|
||||
onClosePicker?.();
|
||||
},
|
||||
[editor, node, onClose]
|
||||
[editor, node, onClosePicker]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
color={'inherit'}
|
||||
onMouseEnter={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
endIcon={<MoreSvg />}
|
||||
startIcon={<ColorLensOutlinedIcon />}
|
||||
size={'small'}
|
||||
className={'mx-2 my-1 justify-start'}
|
||||
>
|
||||
{t('editor.color')}
|
||||
{open && (
|
||||
<>
|
||||
<div ref={ref} onClick={onOpenPicker} className={'flex w-full items-center justify-between gap-2'}>
|
||||
<ColorLensOutlinedIcon className={'h-5 w-5'} />
|
||||
<div className={'flex-1'}>{t('editor.color')}</div>
|
||||
<MoreSvg className={'h-5 w-5'} />
|
||||
</div>
|
||||
{openPicker && (
|
||||
<Popover
|
||||
{...PopoverNoBackdropProps}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
anchorOrigin={initialOrigin.anchorOrigin}
|
||||
transformOrigin={initialOrigin.transformOrigin}
|
||||
autoFocus={true}
|
||||
open={openPicker}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
if (e.key === 'Escape' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
onClosePicker?.();
|
||||
}
|
||||
}}
|
||||
PaperProps={{
|
||||
...PopoverNoBackdropProps.PaperProps,
|
||||
className: 'w-[200px] max-h-[360px] overflow-x-hidden overflow-y-auto',
|
||||
}}
|
||||
open={open}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
anchorEl={ref.current}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={onClosePicker}
|
||||
>
|
||||
<div className={'flex flex-col'}>
|
||||
<FontColorPicker
|
||||
onChange={(color) => {
|
||||
onColorChange('font_color', color);
|
||||
}}
|
||||
color={fontColor}
|
||||
/>
|
||||
<BgColorPicker
|
||||
onChange={(color) => {
|
||||
onColorChange('bg_color', color);
|
||||
}}
|
||||
color={bgColor}
|
||||
/>
|
||||
<ColorPicker onEscape={onClosePicker} onChange={onColorChange} />
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -20,9 +20,77 @@ export function getBlockActionsPosition(editor: ReactEditor, blockElement: HTMLE
|
||||
export function getBlockCssProperty(node: Element) {
|
||||
switch (node.type) {
|
||||
case EditorNodeType.HeadingBlock:
|
||||
return getHeadingCssProperty((node as HeadingNode).data.level);
|
||||
return `${getHeadingCssProperty((node as HeadingNode).data.level)} mt-1`;
|
||||
case EditorNodeType.CodeBlock:
|
||||
case EditorNodeType.CalloutBlock:
|
||||
return 'my-2';
|
||||
case EditorNodeType.EquationBlock:
|
||||
case EditorNodeType.GridBlock:
|
||||
return 'my-3';
|
||||
case EditorNodeType.DividerBlock:
|
||||
return 'my-0';
|
||||
default:
|
||||
return 'mt-1';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve can not find the range when the drop occurs on the icon.
|
||||
* @param editor
|
||||
* @param e
|
||||
*/
|
||||
export function findEventRange(editor: ReactEditor, e: MouseEvent) {
|
||||
const { clientX: x, clientY: y } = e;
|
||||
|
||||
// Else resolve a range from the caret position where the drop occured.
|
||||
let domRange;
|
||||
const { document } = ReactEditor.getWindow(editor);
|
||||
|
||||
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
|
||||
if (document.caretRangeFromPoint) {
|
||||
domRange = document.caretRangeFromPoint(x, y);
|
||||
} else if ('caretPositionFromPoint' in document && typeof document.caretPositionFromPoint === 'function') {
|
||||
const position = document.caretPositionFromPoint(x, y);
|
||||
|
||||
if (position) {
|
||||
domRange = document.createRange();
|
||||
domRange.setStart(position.offsetNode, position.offset);
|
||||
domRange.setEnd(position.offsetNode, position.offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (domRange && domRange.startContainer) {
|
||||
const startContainer = domRange.startContainer;
|
||||
|
||||
let element: HTMLElement | null = startContainer as HTMLElement;
|
||||
const nodeType = element.nodeType;
|
||||
|
||||
if (nodeType === 3 || typeof element === 'string') {
|
||||
const parent = element.parentElement?.closest('.text-block-icon') as HTMLElement;
|
||||
|
||||
element = parent;
|
||||
}
|
||||
|
||||
if (element && element.nodeType < 3) {
|
||||
if (element.classList?.contains('text-block-icon')) {
|
||||
const sibling = domRange.startContainer.parentElement;
|
||||
|
||||
if (sibling) {
|
||||
domRange.selectNode(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!domRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return ReactEditor.toSlateRange(editor, domRange, {
|
||||
exactMatch: false,
|
||||
suppressThrow: false,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,297 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { getPanelPosition } from '$app/components/editor/components/tools/command_panel/utils';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
|
||||
import { Editor, Point, Range, Transforms } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import { useSlashState } from '$app/components/editor/stores';
|
||||
|
||||
export enum EditorCommand {
|
||||
Mention = '@',
|
||||
SlashCommand = '/',
|
||||
}
|
||||
|
||||
export const PanelPopoverProps: Partial<PopoverProps> = {
|
||||
...PopoverPreventBlurProps,
|
||||
anchorReference: 'anchorPosition',
|
||||
};
|
||||
|
||||
const commands = Object.values(EditorCommand);
|
||||
|
||||
export interface PanelProps {
|
||||
anchorPosition?: { left: number; top: number; height: number };
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
searchText: string;
|
||||
openPanel: () => void;
|
||||
}
|
||||
|
||||
export function useCommandPanel() {
|
||||
const editor = useSlate();
|
||||
const { open: slashOpen, setOpen: setSlashOpen } = useSlashState();
|
||||
const [command, setCommand] = useState<EditorCommand | undefined>(undefined);
|
||||
const [anchorPosition, setAnchorPosition] = useState<
|
||||
| {
|
||||
top: number;
|
||||
left: number;
|
||||
height: number;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
const startPoint = useRef<Point>();
|
||||
const endPoint = useRef<Point>();
|
||||
const open = Boolean(anchorPosition);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const closePanel = useCallback(
|
||||
(deleteText?: boolean) => {
|
||||
if (deleteText && startPoint.current && endPoint.current) {
|
||||
const anchor = {
|
||||
path: startPoint.current.path,
|
||||
offset: startPoint.current.offset - 1,
|
||||
};
|
||||
const focus = {
|
||||
path: endPoint.current.path,
|
||||
offset: endPoint.current.offset,
|
||||
};
|
||||
|
||||
Transforms.delete(editor, {
|
||||
at: {
|
||||
anchor,
|
||||
focus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setSlashOpen(false);
|
||||
setCommand(undefined);
|
||||
setAnchorPosition(undefined);
|
||||
setSearchText('');
|
||||
},
|
||||
[editor, setSlashOpen]
|
||||
);
|
||||
|
||||
const setPosition = useCallback(
|
||||
(position?: { left: number; top: number; height: number }) => {
|
||||
if (!position) {
|
||||
closePanel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeEntry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!nodeEntry) return;
|
||||
|
||||
setAnchorPosition(position);
|
||||
},
|
||||
[closePanel, editor]
|
||||
);
|
||||
|
||||
const openPanel = useCallback(() => {
|
||||
const position = getPanelPosition(editor);
|
||||
|
||||
if (position && editor.selection) {
|
||||
startPoint.current = Editor.start(editor, editor.selection);
|
||||
endPoint.current = Editor.end(editor, editor.selection);
|
||||
setPosition(position);
|
||||
} else {
|
||||
setPosition(undefined);
|
||||
}
|
||||
}, [editor, setPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slashOpen && command === EditorCommand.SlashCommand) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (slashOpen && !open) {
|
||||
setCommand(EditorCommand.SlashCommand);
|
||||
openPanel();
|
||||
return;
|
||||
}
|
||||
}, [slashOpen, closePanel, command, open, openPanel]);
|
||||
/**
|
||||
* listen to editor insertText and deleteBackward event
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { insertText, deleteBackward } = editor;
|
||||
|
||||
/**
|
||||
* insertText: when insert char at after space or at start of element, show the panel
|
||||
* open condition:
|
||||
* 1. open is false
|
||||
* 2. current block is not code block
|
||||
* 3. current selection is not include root
|
||||
* 4. current selection is collapsed
|
||||
* 5. insert char is command char
|
||||
* 6. before text is empty or end with space
|
||||
* --------- start -----------------
|
||||
* | - selection point
|
||||
* @ - panel char
|
||||
* _ - space
|
||||
* - - other text
|
||||
* -------- open panel ----------------
|
||||
* ---_@|--- => insert text is panel char and before text is end with space, open the panel
|
||||
* @|--- => insert text is panel char and before text is empty, open the panel
|
||||
*/
|
||||
editor.insertText = (text, opts) => {
|
||||
if (open || CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) {
|
||||
insertText(text, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
const command = commands.find((c) => text.endsWith(c));
|
||||
const endOfPanelChar = !!command;
|
||||
|
||||
if (command === EditorCommand.SlashCommand) {
|
||||
setSlashOpen(true);
|
||||
}
|
||||
|
||||
setCommand(command);
|
||||
if (!selection || !endOfPanelChar || !Range.isCollapsed(selection)) {
|
||||
insertText(text, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const { anchor } = selection;
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1);
|
||||
// show the panel when insert char at after space or at start of element
|
||||
const showPanel = !beforeText || beforeText.endsWith(' ');
|
||||
|
||||
insertText(text, opts);
|
||||
|
||||
if (!showPanel) return;
|
||||
openPanel();
|
||||
};
|
||||
|
||||
/**
|
||||
* deleteBackward: when delete char at start of panel char, and then it will be deleted, so we should close the panel if it is open
|
||||
* close condition:
|
||||
* 1. open is true
|
||||
* 2. current block is not code block
|
||||
* 3. current selection is not include root
|
||||
* 4. current selection is collapsed
|
||||
* 5. before text is command char
|
||||
* --------- start -----------------
|
||||
* | - selection point
|
||||
* @ - panel char
|
||||
* - - other text
|
||||
* -------- close panel ----------------
|
||||
* --@|--- => delete text is panel char, close the panel
|
||||
* -------- delete text ----------------
|
||||
* ---@__|--- => delete text is not panel char, delete the text
|
||||
*/
|
||||
editor.deleteBackward = (...args) => {
|
||||
if (!open || CustomEditor.isCodeBlock(editor)) {
|
||||
deleteBackward(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { anchor } = selection;
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
const path = block ? block[1] : [];
|
||||
const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) });
|
||||
|
||||
deleteBackward(...args);
|
||||
// if delete backward at start of panel char, and then it will be deleted, so we should close the panel if it is open
|
||||
if (beforeText === command) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
editor.insertText = insertText;
|
||||
editor.deleteBackward = deleteBackward;
|
||||
};
|
||||
}, [setSlashOpen, command, open, setPosition, editor, closePanel, openPanel]);
|
||||
|
||||
/**
|
||||
* listen to editor onChange event
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { onChange } = editor;
|
||||
|
||||
if (!open) return;
|
||||
|
||||
/**
|
||||
* onChange: when selection change, update the search text or close the panel
|
||||
* --------- start -----------------
|
||||
* | - selection point
|
||||
* @ - panel char
|
||||
* __ - search text
|
||||
* - - other text
|
||||
* -------- close panel ----------------
|
||||
* --|@--- => selection is backward to start point, close the panel
|
||||
* ---@__-|--- => selection is forward to end point, close the panel
|
||||
* -------- update search text ----------------
|
||||
* ---@__|---
|
||||
* ---@_|_--- => selection is forward to start point and backward to end point, update the search text
|
||||
* ---@|__---
|
||||
* --------- end -----------------
|
||||
*/
|
||||
editor.onChange = (...args) => {
|
||||
if (!editor.selection || !startPoint.current || !endPoint.current) return;
|
||||
onChange(...args);
|
||||
const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection');
|
||||
const currentPoint = Editor.end(editor, editor.selection);
|
||||
const isBackward = currentPoint.offset < startPoint.current.offset;
|
||||
|
||||
if (isBackward) {
|
||||
closePanel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSelectionChange) {
|
||||
if (currentPoint.offset > endPoint.current?.offset) {
|
||||
endPoint.current = currentPoint;
|
||||
}
|
||||
|
||||
const text = Editor.string(editor, {
|
||||
anchor: startPoint.current,
|
||||
focus: endPoint.current,
|
||||
});
|
||||
|
||||
setSearchText(text);
|
||||
} else {
|
||||
const isForward = currentPoint.offset > endPoint.current.offset;
|
||||
|
||||
if (isForward) {
|
||||
closePanel(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
editor.onChange = onChange;
|
||||
};
|
||||
}, [open, editor, closePanel]);
|
||||
|
||||
return {
|
||||
anchorPosition,
|
||||
closePanel,
|
||||
searchText,
|
||||
openPanel,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialTransformOrigin: PopoverOrigin = {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
};
|
||||
|
||||
export const initialAnchorOrigin: PopoverOrigin = {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel';
|
||||
import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel';
|
||||
import { EditorCommand, useCommandPanel } from '$app/components/editor/components/tools/command_panel/Command.hooks';
|
||||
import withErrorBoundary from '$app/components/_shared/error_boundary/withError';
|
||||
|
||||
function CommandPanel() {
|
||||
const { anchorPosition, searchText, openPanel, closePanel, command } = useCommandPanel();
|
||||
|
||||
const Component = command === EditorCommand.SlashCommand ? SlashCommandPanel : MentionPanel;
|
||||
|
||||
return (
|
||||
<Component closePanel={closePanel} searchText={searchText} openPanel={openPanel} anchorPosition={anchorPosition} />
|
||||
);
|
||||
}
|
||||
|
||||
export default withErrorBoundary(CommandPanel);
|
@ -1,81 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { Mention, MentionPage, MentionType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
|
||||
export function useMentionPanel({
|
||||
closePanel,
|
||||
searchText,
|
||||
}: {
|
||||
searchText: string;
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlate();
|
||||
const [selectedOptionId, setSelectedOptionId] = useState<string>('');
|
||||
|
||||
const onClick = useCallback(
|
||||
(type: MentionType, mention: Mention) => {
|
||||
closePanel(true);
|
||||
CustomEditor.insertMention(editor, mention);
|
||||
},
|
||||
[closePanel, editor]
|
||||
);
|
||||
const pagesMap = useAppSelector((state) => state.pages.pageMap);
|
||||
|
||||
const pagesRef = useRef<MentionPage[]>([]);
|
||||
const [recentPages, setPages] = useState<MentionPage[]>([]);
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const pages = Object.values(pagesMap);
|
||||
|
||||
pagesRef.current = pages;
|
||||
setPages(pages);
|
||||
}, [pagesMap]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPages();
|
||||
}, [loadPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchText) {
|
||||
setPages(pagesRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredPages = pagesRef.current.filter((page) => {
|
||||
return page.name.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
setPages(filteredPages);
|
||||
}, [searchText]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: MentionType.PageRef,
|
||||
label: t('document.mention.page.label'),
|
||||
options: recentPages.map((page) => {
|
||||
return {
|
||||
key: page.id,
|
||||
label: page.name,
|
||||
icon: page.icon,
|
||||
onClick: () => {
|
||||
onClick(MentionType.PageRef, {
|
||||
page: page.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
].filter((option) => option.options.length > 0);
|
||||
}, [onClick, recentPages, t]);
|
||||
|
||||
return {
|
||||
options,
|
||||
selectedOptionId,
|
||||
setSelectedOptionId,
|
||||
};
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { MentionPage, MentionType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { ReactComponent as DocumentSvg } from '$app/assets/document.svg';
|
||||
|
||||
export function useMentionPanel({
|
||||
closePanel,
|
||||
searchText,
|
||||
}: {
|
||||
searchText: string;
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlate();
|
||||
|
||||
const pagesMap = useAppSelector((state) => state.pages.pageMap);
|
||||
|
||||
const pagesRef = useRef<MentionPage[]>([]);
|
||||
const [recentPages, setPages] = useState<MentionPage[]>([]);
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const pages = Object.values(pagesMap);
|
||||
|
||||
pagesRef.current = pages;
|
||||
setPages(pages);
|
||||
}, [pagesMap]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPages();
|
||||
}, [loadPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchText) {
|
||||
setPages(pagesRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredPages = pagesRef.current.filter((page) => {
|
||||
return page.name.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
setPages(filteredPages);
|
||||
}, [searchText]);
|
||||
|
||||
const onConfirm = useCallback(
|
||||
(key: string) => {
|
||||
const [, id] = key.split(',');
|
||||
|
||||
closePanel(true);
|
||||
CustomEditor.insertMention(editor, {
|
||||
page: id,
|
||||
});
|
||||
},
|
||||
[closePanel, editor]
|
||||
);
|
||||
|
||||
const renderPage = useCallback(
|
||||
(page: MentionPage) => {
|
||||
return {
|
||||
key: `${MentionType.PageRef},${page.id}`,
|
||||
content: (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex h-5 w-5 items-center justify-center'}>{page.icon?.value || <DocumentSvg />}</div>
|
||||
|
||||
<div className={'flex-1'}>{page.name || t('document.title.placeholder')}</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const options: KeyboardNavigationOption<MentionType | string>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: MentionType.PageRef,
|
||||
content: <div className={'px-3 pb-1 pt-2 text-sm'}>{t('document.mention.page.label')}</div>,
|
||||
children: recentPages.map(renderPage),
|
||||
},
|
||||
].filter((option) => option.children.length > 0);
|
||||
}, [recentPages, renderPage, t]);
|
||||
|
||||
return {
|
||||
options,
|
||||
onConfirm,
|
||||
};
|
||||
}
|
@ -1,20 +1,53 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { PanelPopoverProps, usePanel } from '$app/components/editor/components/tools/command_panel/usePanel.hooks';
|
||||
import {
|
||||
initialAnchorOrigin,
|
||||
initialTransformOrigin,
|
||||
PanelPopoverProps,
|
||||
PanelProps,
|
||||
} from '$app/components/editor/components/tools/command_panel/Command.hooks';
|
||||
import Popover from '@mui/material/Popover';
|
||||
|
||||
import MentionPanelContent from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent';
|
||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||
|
||||
export function MentionPanel() {
|
||||
export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { anchorPosition, closePanel, searchText } = usePanel(ref);
|
||||
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
const {
|
||||
paperHeight,
|
||||
anchorPosition: newAnchorPosition,
|
||||
paperWidth,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
isEntered,
|
||||
} = usePopoverAutoPosition({
|
||||
initialPaperWidth: 300,
|
||||
initialPaperHeight: 360,
|
||||
anchorPosition,
|
||||
initialTransformOrigin,
|
||||
initialAnchorOrigin,
|
||||
open,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'mention-panel'}>
|
||||
{open && (
|
||||
<Popover {...PanelPopoverProps} open={open} anchorPosition={anchorPosition} onClose={() => closePanel(false)}>
|
||||
<MentionPanelContent closePanel={closePanel} searchText={searchText} />
|
||||
<Popover
|
||||
{...PanelPopoverProps}
|
||||
open={open && isEntered}
|
||||
anchorPosition={newAnchorPosition}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
onClose={() => closePanel(false)}
|
||||
>
|
||||
<MentionPanelContent
|
||||
width={paperWidth}
|
||||
maxHeight={paperHeight}
|
||||
closePanel={closePanel}
|
||||
searchText={searchText}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,75 +1,42 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useMentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks';
|
||||
import { useKeyDown } from '$app/components/editor/components/tools/command_panel/usePanel.hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MenuItem, MenuList, Typography } from '@mui/material';
|
||||
import { ReactComponent as DocumentSvg } from '$app/assets/document.svg';
|
||||
|
||||
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
|
||||
function MentionPanelContent({
|
||||
closePanel,
|
||||
searchText,
|
||||
maxHeight,
|
||||
width,
|
||||
}: {
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
searchText: string;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { options, selectedOptionId, setSelectedOptionId } = useMentionPanel({
|
||||
const { options, onConfirm } = useMentionPanel({
|
||||
closePanel,
|
||||
searchText,
|
||||
});
|
||||
|
||||
const handleSelectKey = useCallback(
|
||||
(key?: string | number) => {
|
||||
setSelectedOptionId(String(key));
|
||||
},
|
||||
[setSelectedOptionId]
|
||||
);
|
||||
|
||||
useKeyDown({
|
||||
scrollRef,
|
||||
panelOpen: true,
|
||||
closePanel,
|
||||
options,
|
||||
selectedKey: selectedOptionId,
|
||||
setSelectedKey: handleSelectKey,
|
||||
});
|
||||
return (
|
||||
<div ref={scrollRef} className={'max-h-[360px] w-[300px] overflow-auto overflow-x-hidden'}>
|
||||
{options.length === 0 ? (
|
||||
<Typography variant='body1' className={'p-3 text-text-caption'}>
|
||||
No results
|
||||
</Typography>
|
||||
) : (
|
||||
options.map((option, index) => (
|
||||
<div key={option.key} className={`${index !== 0 ? 'border-t border-line-divider' : ''}`}>
|
||||
<Typography variant='body1' className={'p-3 px-4 text-text-caption'}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
<MenuList className={'px-2 pb-3 pt-0'}>
|
||||
{option.options.map((subOption) => {
|
||||
return (
|
||||
<MenuItem
|
||||
onMouseEnter={() => setSelectedOptionId(subOption.key)}
|
||||
selected={selectedOptionId === subOption.key}
|
||||
data-type={subOption.key}
|
||||
className={'ml-0 flex w-full items-center justify-start px-2 py-1'}
|
||||
key={subOption.key}
|
||||
onClick={subOption.onClick}
|
||||
>
|
||||
<div className={'h-4 w-4'}>{subOption.icon?.value || <DocumentSvg />}</div>
|
||||
|
||||
<Typography variant='body1' className={'ml-2 text-xs'}>
|
||||
{subOption.label || t('document.title.placeholder')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
maxHeight,
|
||||
width,
|
||||
}}
|
||||
className={' overflow-auto overflow-x-hidden'}
|
||||
>
|
||||
<KeyboardNavigation
|
||||
scrollRef={scrollRef}
|
||||
onEscape={closePanel}
|
||||
onConfirm={onConfirm}
|
||||
options={options}
|
||||
disableFocus={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSlate } from 'slate-react';
|
||||
import { Transforms } from 'slate';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Path } from 'slate';
|
||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||
import { ReactComponent as TextIcon } from '$app/assets/text.svg';
|
||||
import { ReactComponent as TodoListIcon } from '$app/assets/todo-list.svg';
|
||||
@ -17,13 +17,15 @@ import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
|
||||
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { randomEmoji } from '$app/utils/emoji';
|
||||
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
|
||||
enum SlashCommandPanelTab {
|
||||
BASIC = 'basic',
|
||||
ADVANCED = 'advanced',
|
||||
}
|
||||
|
||||
enum SlashOptionType {
|
||||
export enum SlashOptionType {
|
||||
Paragraph,
|
||||
TodoList,
|
||||
Heading1,
|
||||
@ -89,16 +91,13 @@ const headingTypes = [SlashOptionType.Heading1, SlashOptionType.Heading2, SlashO
|
||||
export function useSlashCommandPanel({
|
||||
searchText,
|
||||
closePanel,
|
||||
open,
|
||||
}: {
|
||||
searchText: string;
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlate();
|
||||
const [selectedType, setSelectedType] = useState(SlashOptionType.Paragraph);
|
||||
const onClick = useCallback(
|
||||
const onConfirm = useCallback(
|
||||
(type: SlashOptionType) => {
|
||||
const node = getBlock(editor);
|
||||
|
||||
@ -124,20 +123,26 @@ export function useSlashCommandPanel({
|
||||
|
||||
if (nodeType === EditorNodeType.CodeBlock) {
|
||||
Object.assign(data, {
|
||||
language: 'javascript',
|
||||
language: 'json',
|
||||
});
|
||||
}
|
||||
|
||||
closePanel(true);
|
||||
|
||||
const newNode = getBlock(editor);
|
||||
const block = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!newNode) return;
|
||||
const path = block ? block[1] : null;
|
||||
|
||||
const isEmpty = CustomEditor.isEmptyText(editor, newNode);
|
||||
if (!newNode || !path) return;
|
||||
|
||||
const isEmpty = CustomEditor.isEmptyText(editor, newNode) && newNode.type === EditorNodeType.Paragraph;
|
||||
|
||||
if (!isEmpty) {
|
||||
Transforms.splitNodes(editor, { always: true });
|
||||
const nextPath = Path.next(path);
|
||||
|
||||
CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath);
|
||||
editor.select(nextPath);
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
@ -155,7 +160,7 @@ export function useSlashCommandPanel({
|
||||
Icon: TextIcon,
|
||||
},
|
||||
[SlashOptionType.TodoList]: {
|
||||
label: t('document.plugins.todoList'),
|
||||
label: t('editor.checkbox'),
|
||||
Icon: TodoListIcon,
|
||||
},
|
||||
[SlashOptionType.Heading1]: {
|
||||
@ -217,50 +222,55 @@ export function useSlashCommandPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const renderOptionContent = useCallback(
|
||||
(type: SlashOptionType) => {
|
||||
const Icon = typeToLabelIconMap[type].Icon;
|
||||
|
||||
return (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex h-6 w-6 items-center justify-center'}>
|
||||
<Icon className={'h-4 w-4'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex-1'}>{typeToLabelIconMap[type].label}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[typeToLabelIconMap]
|
||||
);
|
||||
|
||||
const options: KeyboardNavigationOption<SlashOptionType | SlashCommandPanelTab>[] = useMemo(() => {
|
||||
return slashOptionGroup
|
||||
.map((group) => {
|
||||
return {
|
||||
key: group.key,
|
||||
label: groupTypeToLabelMap[group.key],
|
||||
options: group.options
|
||||
content: <div className={'px-3 pb-1 pt-2 text-sm'}>{groupTypeToLabelMap[group.key]}</div>,
|
||||
children: group.options
|
||||
.map((type) => {
|
||||
return {
|
||||
key: type,
|
||||
label: typeToLabelIconMap[type].label,
|
||||
Icon: typeToLabelIconMap[type].Icon,
|
||||
onClick: () => onClick(type),
|
||||
content: renderOptionContent(type),
|
||||
};
|
||||
})
|
||||
.filter((option) => {
|
||||
if (!searchText) return true;
|
||||
return option.label.toLowerCase().includes(searchText.toLowerCase());
|
||||
const label = typeToLabelIconMap[option.key].label;
|
||||
|
||||
let newSearchText = searchText;
|
||||
|
||||
if (searchText.startsWith('/')) {
|
||||
newSearchText = searchText.slice(1);
|
||||
}
|
||||
|
||||
return label.toLowerCase().includes(newSearchText.toLowerCase());
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.options.length > 0);
|
||||
}, [groupTypeToLabelMap, onClick, searchText, typeToLabelIconMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const node = getBlock(editor);
|
||||
|
||||
if (!node) return;
|
||||
const nodeType = node.type;
|
||||
|
||||
const optionType = Object.entries(slashOptionMapToEditorNodeType).find(([, type]) => type === nodeType);
|
||||
|
||||
if (optionType) {
|
||||
setSelectedType(Number(optionType[0]));
|
||||
}
|
||||
} else {
|
||||
setSelectedType(SlashOptionType.Paragraph);
|
||||
}
|
||||
}, [editor, open]);
|
||||
.filter((group) => group.children.length > 0);
|
||||
}, [searchText, groupTypeToLabelMap, typeToLabelIconMap, renderOptionContent]);
|
||||
|
||||
return {
|
||||
options,
|
||||
selectedType,
|
||||
setSelectedType,
|
||||
onConfirm,
|
||||
};
|
||||
}
|
@ -1,34 +1,69 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { PanelPopoverProps, usePanel } from '$app/components/editor/components/tools/command_panel/usePanel.hooks';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import {
|
||||
initialAnchorOrigin,
|
||||
initialTransformOrigin,
|
||||
PanelPopoverProps,
|
||||
PanelProps,
|
||||
} from '$app/components/editor/components/tools/command_panel/Command.hooks';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent';
|
||||
import { useSlate } from 'slate-react';
|
||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||
|
||||
export function SlashCommandPanel() {
|
||||
export function SlashCommandPanel({ anchorPosition, closePanel, searchText }: PanelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const editor = useSlate();
|
||||
const { anchorPosition, closePanel, searchText } = usePanel(ref);
|
||||
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(deleteText?: boolean) => {
|
||||
closePanel(deleteText);
|
||||
},
|
||||
[closePanel]
|
||||
);
|
||||
|
||||
const {
|
||||
paperHeight,
|
||||
paperWidth,
|
||||
anchorPosition: newAnchorPosition,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
isEntered,
|
||||
} = usePopoverAutoPosition({
|
||||
initialPaperWidth: 220,
|
||||
initialPaperHeight: 360,
|
||||
anchorPosition,
|
||||
initialTransformOrigin,
|
||||
initialAnchorOrigin,
|
||||
open,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'slash-command-panel'}>
|
||||
{open && (
|
||||
<Popover
|
||||
{...PanelPopoverProps}
|
||||
open={open}
|
||||
anchorPosition={anchorPosition}
|
||||
open={open && isEntered}
|
||||
anchorPosition={newAnchorPosition}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
onClose={() => {
|
||||
const selection = editor.selection;
|
||||
|
||||
closePanel(false);
|
||||
handleClose(false);
|
||||
|
||||
if (selection) {
|
||||
editor.select(selection);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SlashCommandPanelContent closePanel={closePanel} searchText={searchText} />
|
||||
<SlashCommandPanelContent
|
||||
width={paperWidth}
|
||||
maxHeight={paperHeight}
|
||||
closePanel={handleClose}
|
||||
searchText={searchText}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,74 +1,89 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { MenuItem, MenuList, Typography } from '@mui/material';
|
||||
import { useSlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks';
|
||||
import { useKeyDown } from '$app/components/editor/components/tools/command_panel/usePanel.hooks';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import {
|
||||
SlashOptionType,
|
||||
useSlashCommandPanel,
|
||||
} from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
|
||||
const noResultBuffer = 2;
|
||||
|
||||
function SlashCommandPanelContent({
|
||||
closePanel,
|
||||
searchText,
|
||||
maxHeight,
|
||||
width,
|
||||
}: {
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
searchText: string;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { options, selectedType, setSelectedType } = useSlashCommandPanel({
|
||||
const { options, onConfirm } = useSlashCommandPanel({
|
||||
closePanel,
|
||||
searchText,
|
||||
closePanel,
|
||||
open: true,
|
||||
});
|
||||
|
||||
const handleSelectType = useCallback(
|
||||
(type?: string | number) => {
|
||||
if (type === undefined) return;
|
||||
// Used to keep track of how many times the user has typed and not found any result
|
||||
const noResultCount = useRef(0);
|
||||
|
||||
setSelectedType(Number(type));
|
||||
},
|
||||
[setSelectedType]
|
||||
);
|
||||
const editor = useSlateStatic();
|
||||
|
||||
useEffect(() => {
|
||||
const { insertText, deleteBackward } = editor;
|
||||
|
||||
editor.insertText = (text, opts) => {
|
||||
// close panel if track of no result is greater than buffer
|
||||
if (noResultCount.current >= noResultBuffer) {
|
||||
closePanel(false);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
noResultCount.current += 1;
|
||||
}
|
||||
|
||||
insertText(text, opts);
|
||||
};
|
||||
|
||||
editor.deleteBackward = (unit) => {
|
||||
// reset no result count
|
||||
if (noResultCount.current > 0) {
|
||||
noResultCount.current -= 1;
|
||||
}
|
||||
|
||||
// close panel if no text
|
||||
if (!searchText) {
|
||||
closePanel(true);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteBackward(unit);
|
||||
};
|
||||
|
||||
return () => {
|
||||
editor.insertText = insertText;
|
||||
editor.deleteBackward = deleteBackward;
|
||||
};
|
||||
}, [closePanel, editor, searchText, options.length]);
|
||||
|
||||
useKeyDown({
|
||||
scrollRef,
|
||||
panelOpen: true,
|
||||
closePanel,
|
||||
options,
|
||||
selectedKey: selectedType,
|
||||
setSelectedKey: handleSelectType,
|
||||
});
|
||||
return (
|
||||
<div ref={scrollRef} className={'max-h-[360px] w-[220px] overflow-auto overflow-x-hidden py-1 pl-1'}>
|
||||
{options.length > 0 ? (
|
||||
options.map((group) => (
|
||||
<div key={group.key}>
|
||||
<Typography variant='body1' className={'p-2 text-text-caption'}>
|
||||
{group.label}
|
||||
</Typography>
|
||||
<MenuList className={'py-0 pl-1'}>
|
||||
{group.options.map((subOption) => {
|
||||
const Icon = subOption.Icon;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onMouseEnter={() => setSelectedType(subOption.key)}
|
||||
selected={selectedType === subOption.key}
|
||||
data-type={subOption.key}
|
||||
className={'ml-0 flex w-full items-center justify-start'}
|
||||
key={subOption.key}
|
||||
onClick={subOption.onClick}
|
||||
>
|
||||
<Icon className={'mr-2 h-4 w-4'} />
|
||||
<div className={'flex-1'}>{subOption.label}</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Typography variant='body1' className={'p-3 text-text-caption'}>
|
||||
No results
|
||||
</Typography>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
maxHeight,
|
||||
width,
|
||||
}}
|
||||
className={'overflow-auto overflow-x-hidden py-1'}
|
||||
>
|
||||
<KeyboardNavigation
|
||||
scrollRef={scrollRef}
|
||||
onEscape={closePanel}
|
||||
onConfirm={(key) => onConfirm(key as SlashOptionType)}
|
||||
options={options}
|
||||
disableFocus={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,256 +0,0 @@
|
||||
import { useEffect, RefObject, useState, useCallback, useRef } from 'react';
|
||||
import { getPanelPosition } from '$app/components/editor/components/tools/command_panel/utils';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
import { commandPanelShowProperty } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts';
|
||||
import { Editor, Point, Transforms } from 'slate';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
export const PanelPopoverProps: Partial<PopoverProps> = {
|
||||
...PopoverPreventBlurProps,
|
||||
onMouseUp: (e) => e.stopPropagation(),
|
||||
transformOrigin: {
|
||||
vertical: -28,
|
||||
horizontal: 'left',
|
||||
},
|
||||
anchorReference: 'anchorPosition',
|
||||
};
|
||||
|
||||
export function usePanel(ref: RefObject<HTMLDivElement | null>) {
|
||||
const editor = useSlate();
|
||||
const [anchorPosition, setAnchorPosition] = useState<
|
||||
| {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
const startPoint = useRef<Point>();
|
||||
const endPoint = useRef<Point>();
|
||||
const open = Boolean(anchorPosition);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const closePanel = useCallback(
|
||||
(deleteText?: boolean) => {
|
||||
ref.current?.classList.remove(commandPanelShowProperty);
|
||||
|
||||
if (deleteText && startPoint.current && endPoint.current) {
|
||||
const anchor = {
|
||||
path: startPoint.current.path,
|
||||
offset: startPoint.current.offset - 1,
|
||||
};
|
||||
const focus = {
|
||||
path: endPoint.current.path,
|
||||
offset: endPoint.current.offset,
|
||||
};
|
||||
|
||||
Transforms.delete(editor, {
|
||||
at: {
|
||||
anchor,
|
||||
focus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setAnchorPosition(undefined);
|
||||
setSearchText('');
|
||||
},
|
||||
[editor, ref]
|
||||
);
|
||||
|
||||
const setPosition = useCallback(
|
||||
(position?: { left: number; top: number }) => {
|
||||
if (!position) {
|
||||
closePanel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeEntry = CustomEditor.getBlock(editor);
|
||||
|
||||
if (!nodeEntry) return;
|
||||
|
||||
setAnchorPosition({
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
});
|
||||
},
|
||||
[closePanel, editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
let prevState = el.classList.contains(commandPanelShowProperty);
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
const { target } = mutation;
|
||||
|
||||
if (mutation.attributeName === 'class') {
|
||||
const currentState = (target as HTMLElement).classList.contains(commandPanelShowProperty);
|
||||
|
||||
if (prevState !== currentState) {
|
||||
prevState = currentState;
|
||||
if (currentState) {
|
||||
const position = getPanelPosition(editor);
|
||||
|
||||
if (position && editor.selection) {
|
||||
startPoint.current = Editor.start(editor, editor.selection);
|
||||
endPoint.current = Editor.end(editor, editor.selection);
|
||||
setPosition(position);
|
||||
} else {
|
||||
setPosition(undefined);
|
||||
}
|
||||
} else {
|
||||
setPosition(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(el, { attributes: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [setPosition, editor, ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const { onChange } = editor;
|
||||
|
||||
if (open) {
|
||||
editor.onChange = (...args) => {
|
||||
if (!editor.selection || !startPoint.current || !endPoint.current) return;
|
||||
onChange(...args);
|
||||
const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection');
|
||||
const currentPoint = Editor.end(editor, editor.selection);
|
||||
const isBackward = currentPoint.offset < startPoint.current.offset;
|
||||
|
||||
if (isBackward) {
|
||||
closePanel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSelectionChange) {
|
||||
if (currentPoint.offset > endPoint.current?.offset) {
|
||||
endPoint.current = currentPoint;
|
||||
}
|
||||
|
||||
const text = Editor.string(editor, {
|
||||
anchor: startPoint.current,
|
||||
focus: endPoint.current,
|
||||
});
|
||||
|
||||
setSearchText(text);
|
||||
} else {
|
||||
const isForward = currentPoint.offset > endPoint.current.offset;
|
||||
|
||||
if (isForward) {
|
||||
closePanel(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
editor.onChange = onChange;
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.onChange = onChange;
|
||||
};
|
||||
}, [open, editor, closePanel]);
|
||||
|
||||
return {
|
||||
anchorPosition,
|
||||
closePanel,
|
||||
searchText,
|
||||
};
|
||||
}
|
||||
|
||||
export function useKeyDown({
|
||||
scrollRef: ref,
|
||||
options,
|
||||
panelOpen: open,
|
||||
setSelectedKey,
|
||||
selectedKey,
|
||||
closePanel,
|
||||
}: {
|
||||
panelOpen: boolean;
|
||||
selectedKey?: string | number;
|
||||
setSelectedKey: (key?: string | number) => void;
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
options: {
|
||||
key: string | number;
|
||||
label: string;
|
||||
options: { key: string | number; label: string; onClick: () => void }[];
|
||||
}[];
|
||||
}) {
|
||||
const editor = useSlate();
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const flattenOptions = options.flatMap((group) => group.options);
|
||||
|
||||
const index = flattenOptions.findIndex((option) => option.key === selectedKey);
|
||||
const option = flattenOptions[index];
|
||||
const nextIndex = (index + 1) % flattenOptions.length;
|
||||
const prevIndex = (index - 1 + flattenOptions.length) % flattenOptions.length;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
closePanel(false);
|
||||
break;
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
const nextOption = flattenOptions[nextIndex].key;
|
||||
const dom = ref.current?.querySelector(`[data-type="${nextOption}"]`);
|
||||
|
||||
setSelectedKey(nextOption);
|
||||
|
||||
dom?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
const prevOption = flattenOptions[prevIndex].key;
|
||||
const prevDom = ref.current?.querySelector(`[data-type="${prevOption}"]`);
|
||||
|
||||
setSelectedKey(prevOption);
|
||||
prevDom?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
option.onClick();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedKey, options, closePanel, ref, setSelectedKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const slateDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
if (open) {
|
||||
slateDom.addEventListener('keydown', handleKeyDown);
|
||||
} else {
|
||||
slateDom.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
slateDom.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [editor, handleKeyDown, open]);
|
||||
}
|
@ -19,8 +19,12 @@ export function getPanelPosition(editor: ReactEditor) {
|
||||
const rect = domRange?.getBoundingClientRect();
|
||||
|
||||
if (!rect) return null;
|
||||
const nodeDom = domSelection.anchorNode?.parentElement?.closest('.text-element');
|
||||
const height = (nodeDom?.getBoundingClientRect().height ?? 0) + 8;
|
||||
|
||||
return {
|
||||
...rect,
|
||||
height,
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
};
|
||||
|
@ -18,4 +18,10 @@ export const notify = {
|
||||
loading: (message: string) => {
|
||||
toast.loading(message, commonOptions);
|
||||
},
|
||||
info: (message: string) => {
|
||||
toast(message, commonOptions);
|
||||
},
|
||||
clear: () => {
|
||||
toast.dismiss();
|
||||
},
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user