fix: fixed editor bugs (#4552)

* fix: fixed editor bugs

* fix: add error boundary
This commit is contained in:
Kilu.He 2024-02-02 05:47:41 +08:00 committed by GitHub
parent 5b030303a6
commit 9746852b5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
151 changed files with 5619 additions and 2619 deletions

View File

@ -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",

View File

@ -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

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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';

View File

@ -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

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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`}>

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (

View File

@ -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 (

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,3 @@
export * from './CollaborativeEditor';
export * from './Editor';
export { useDecorateCodeHighlight } from '$app/components/editor/components/editor/Editor.hooks';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,2 @@
export * from './CustomColorPicker';
export * from './FontColorPicker';
export * from './ColorPicker';
export * from './BgColorPicker';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -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