feat: support block align (#4219)

* fix: mention bugs

* feat: support to align block range

* fix: inline formula bugs

* fix: adjust UI of color picker

* fix: tab bugs
This commit is contained in:
Kilu.He 2023-12-29 13:50:06 +08:00 committed by GitHub
parent 69469e9989
commit d2ccec79e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 1455 additions and 1247 deletions

View File

@ -100,7 +100,7 @@ export interface MathEquationNode extends Element {
export interface FormulaNode extends Element {
type: EditorInlineNodeType.Formula;
data: boolean;
data: string;
}
export interface MentionNode extends Element {
@ -173,51 +173,15 @@ export interface EditorElementProps<T = Element> extends HTMLAttributes<HTMLDivE
node: T;
}
export interface EditorInlineAttributes {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
font_color?: string;
bg_color?: string;
href?: string;
code?: boolean;
formula?: boolean;
prism_token?: string;
mention?: {
type: string;
// inline page ref id
page?: string;
// reminder date ref id
date?: string;
};
}
export enum EditorMarkFormat {
Bold = 'bold',
Italic = 'italic',
Underline = 'underline',
StrikeThrough = 'strikethrough',
Code = 'code',
Formula = 'formula',
}
export enum EditorStyleFormat {
FontColor = 'font_color',
BackgroundColor = 'bg_color',
Href = 'href',
}
export enum EditorTurnFormat {
Paragraph = 'paragraph',
Heading1 = 'heading1', // 'heading1' is a special format, it's not a slate node type, but a slate node type's data
Heading2 = 'heading2',
Heading3 = 'heading3',
TodoList = 'todo_list',
BulletedList = 'bulleted_list',
NumberedList = 'numbered_list',
Quote = 'quote',
ToggleList = 'toggle_list',
FontColor = 'font_color',
BgColor = 'bg_color',
}
export enum MentionType {

View File

@ -53,8 +53,9 @@ function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions,
option.onClick();
handleClose();
}}
className={'flex items-center gap-1 rounded-none px-2 text-xs font-medium'}
>
<span className={'mr-2'}>{option.icon}</span>
<span className={'text-base'}>{option.icon}</span>
<span>{option.label}</span>
</MenuItem>
))}

View File

@ -5,7 +5,7 @@ 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 { chunkArray } from '$app/utils/tool';
import chunk from 'lodash-es/chunk';
export const EMOJI_SIZE = 32;
@ -154,7 +154,7 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize:
id: category.id,
type: 'category',
});
chunkArray(category.emojis, rowSize).forEach((chunk, index) => {
chunk(category.emojis, rowSize).forEach((chunk, index) => {
rows.push({
type: 'emojis',
emojis: chunk,

View File

@ -6,7 +6,7 @@ import { updatePageName } from '$app_reducers/pages/async_actions';
export const DatabaseTitle = () => {
const viewId = useViewId();
const pageName = useAppSelector((state) => state.pages.pageMap[viewId].name);
const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || '');
const dispatch = useAppDispatch();
const handleInput = useCallback<FormEventHandler>(

View File

@ -40,7 +40,7 @@ export const TextCell: FC<TextCellProps> = ({ placeholder, cell }) => {
return (
<>
<CellText className={`min-h-[36px] w-full`} ref={cellRef} onClick={handleClick}>
<CellText className={`min-h-[36px] w-full cursor-text`} ref={cellRef} onClick={handleClick}>
{content}
</CellText>
<Suspense>

View File

@ -1,6 +1,5 @@
import { Button, Tooltip } from '@mui/material';
import { DragEventHandler, FC, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { throttle } from '$app/utils/tool';
import { useViewId } from '$app/hooks';
import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared';
import { fieldService, Field } from '../../application';
@ -9,6 +8,7 @@ import GridResizer from '$app/components/database/grid/GridField/GridResizer';
import GridFieldMenu from '$app/components/database/grid/GridField/GridFieldMenu';
import { areEqual } from 'react-window';
import { useOpenMenu } from '$app/components/database/grid/GridStickyHeader/GridStickyHeader.hooks';
import throttle from 'lodash-es/throttle';
export interface GridFieldProps extends HTMLAttributes<HTMLDivElement> {
field: Field;
@ -88,9 +88,9 @@ export const GridField: FC<GridFieldProps> = memo(
const [menuAnchorPosition, setMenuAnchorPosition] = useState<
| {
top: number;
left: number;
}
top: number;
left: number;
}
| undefined
>(undefined);
@ -164,8 +164,9 @@ export const GridField: FC<GridFieldProps> = memo(
/>
{isOver && (
<div
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
}`}
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
}`}
/>
)}
<GridResizer field={field} onWidthChange={resizeColumnWidth} />

View File

@ -49,7 +49,7 @@ function GridNewRow({ index, groupId, getContainerRef }: Props) {
toggleCssProperty(false);
}}
onClick={handleClick}
className={'grid-new-row flex grow'}
className={'grid-new-row flex grow cursor-pointer'}
>
<span
style={{

View File

@ -1,17 +1,23 @@
import { ReactEditor } from 'slate-react';
import { Editor, Element as SlateElement, Range, Transforms } from 'slate';
import { EditorInlineNodeType } from '$app/application/document/document.types';
import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate';
import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types';
export function insertFormula(editor: ReactEditor, formula?: string) {
export function insertFormula(editor: ReactEditor) {
if (editor.selection) {
wrapFormula(editor, formula);
wrapFormula(editor);
}
}
export function updateFormula(editor: ReactEditor, formula: string) {
if (isFormulaActive(editor)) {
Transforms.delete(editor);
insertFormula(editor, formula);
wrapFormula(editor, formula);
}
}
export function deleteFormula(editor: ReactEditor) {
if (isFormulaActive(editor)) {
Transforms.delete(editor);
}
}
@ -21,32 +27,55 @@ export function wrapFormula(editor: ReactEditor, formula?: string) {
}
const { selection } = editor;
if (!selection) return;
const isCollapsed = selection && Range.isCollapsed(selection);
const data = formula || editor.string(selection);
const formulaElement = {
type: EditorInlineNodeType.Formula,
data: true,
children: isCollapsed
? [
{
text: formula || '',
},
]
: [],
data,
children: [
{
text: '$',
},
],
};
if (isCollapsed) {
Transforms.insertNodes(editor, formulaElement);
} else {
Transforms.wrapNodes(editor, formulaElement, { split: true });
Transforms.collapse(editor, { edge: 'end' });
if (!isCollapsed) {
Transforms.delete(editor);
}
Transforms.insertNodes(editor, formulaElement, {
select: true,
});
}
export function unwrapFormula(editor: ReactEditor) {
Transforms.unwrapNodes(editor, {
const [match] = Editor.nodes(editor, {
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula,
});
if (!match) return;
const [node, path] = match as NodeEntry<FormulaNode>;
const formula = node.data;
const range = Editor.range(editor, match[1]);
const beforePoint = Editor.before(editor, path, { unit: 'character' });
Transforms.select(editor, range);
Transforms.delete(editor);
Transforms.insertText(editor, formula);
if (!beforePoint) return;
Transforms.select(editor, {
anchor: beforePoint,
focus: {
...beforePoint,
offset: beforePoint.offset + formula.length,
},
});
}
export function isFormulaActive(editor: ReactEditor) {

View File

@ -2,7 +2,13 @@ import { ReactEditor } from 'slate-react';
import { Editor, Element, Node, NodeEntry, Point, Range, Transforms, Location } from 'slate';
import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab';
import { isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark';
import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula';
import {
deleteFormula,
insertFormula,
isFormulaActive,
unwrapFormula,
updateFormula,
} from '$app/components/editor/command/formula';
import {
EditorInlineNodeType,
EditorNodeType,
@ -82,13 +88,12 @@ export const CustomEditor = {
isMarkActive,
isFormulaActive,
updateFormula,
toggleInlineElement: (editor: ReactEditor, format: EditorInlineNodeType) => {
if (format === EditorInlineNodeType.Formula) {
if (isFormulaActive(editor)) {
unwrapFormula(editor);
} else {
insertFormula(editor);
}
deleteFormula,
toggleFormula: (editor: ReactEditor) => {
if (isFormulaActive(editor)) {
unwrapFormula(editor);
} else {
insertFormula(editor);
}
},
@ -102,6 +107,47 @@ export const CustomEditor = {
return !!match;
},
toggleAlign(editor: ReactEditor, format: string) {
const matchNodes = Array.from(
Editor.nodes(editor, {
match: (n) => Element.isElement(n) && n.blockId !== undefined,
})
);
if (!matchNodes) return;
matchNodes.forEach((match) => {
const [node] = match as NodeEntry<
Element & {
data: {
align?: string;
};
}
>;
const path = ReactEditor.findPath(editor, node);
const data = (node.data as { align?: string }) || {};
const newProperties = {
data: {
...data,
align: data.align === format ? undefined : format,
},
} as Partial<Element>;
Transforms.setNodes(editor, newProperties, { at: path });
});
},
getAlign(editor: ReactEditor) {
const match = CustomEditor.getBlock(editor);
if (!match) return undefined;
const [node] = match as NodeEntry<Element>;
return (node.data as { align?: string })?.align;
},
insertMention(editor: ReactEditor, mention: Mention) {
const mentionElement = {
type: EditorInlineNodeType.Mention,
@ -111,8 +157,9 @@ export const CustomEditor = {
},
};
Transforms.insertNodes(editor, mentionElement);
Transforms.move(editor);
Transforms.insertNodes(editor, mentionElement, {
select: true,
});
},
toggleTodo(editor: ReactEditor, node: TodoListNode) {

View File

@ -1,10 +1,11 @@
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { Editor, Text, Range } from 'slate';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function toggleMark(
editor: ReactEditor,
mark: {
key: string;
key: EditorMarkFormat;
value: string | boolean;
}
) {
@ -13,13 +14,55 @@ export function toggleMark(
const isActive = isMarkActive(editor, key);
if (isActive) {
Editor.removeMark(editor, key);
Editor.removeMark(editor, key as string);
} else {
Editor.addMark(editor, key, value);
Editor.addMark(editor, key as string, value);
}
}
export function isMarkActive(editor: ReactEditor, format: string) {
export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) {
const selection = editor.selection;
if (!selection) return false;
const isExpanded = Range.isExpanded(selection);
if (isExpanded) {
let anchor = Range.start(selection);
const focus = Range.end(selection);
const isEnd = Editor.isEnd(editor, anchor, anchor.path);
if (isEnd) {
const after = Editor.after(editor, anchor);
if (after) {
anchor = after;
}
}
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];
});
}
const marks = Editor.marks(editor) as Record<string, string | boolean> | null;
return marks ? !!marks[format] : false;

View File

@ -65,13 +65,13 @@ export function tabForward(editor: ReactEditor) {
to: toPath,
});
node.children.forEach((child, index) => {
if (index === 0) return;
const length = node.children.length;
for (let i = length - 1; i > 0; i--) {
editor.liftNodes({
at: [...toPath, index],
at: [...toPath, i],
});
});
}
}
export function tabBackward(editor: ReactEditor) {
@ -81,6 +81,8 @@ export function tabBackward(editor: ReactEditor) {
const [node, path] = match as NodeEntry<Element & { level: number }>;
const depth = path.length;
if (node.type === EditorNodeType.Page) return;
if (node.type !== EditorNodeType.Paragraph) {
CustomEditor.turnToBlock(editor, {
@ -89,6 +91,7 @@ export function tabBackward(editor: ReactEditor) {
return;
}
if (depth === 1) return;
editor.liftNodes({
at: path,
});

View File

@ -1,13 +1,14 @@
import React, { CSSProperties, useMemo } from 'react';
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
import { Editor, Element } from 'slate';
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 selected = useSelected();
const selected = useSelected() && !!editor.selection && Range.isCollapsed(editor.selection);
const block = useMemo(() => {
const path = ReactEditor.findPath(editor, node);
const match = Editor.above(editor, {
@ -21,7 +22,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
}, [editor, node]);
const className = useMemo(() => {
return `pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${
return `pointer-events-none select-none mx-1 absolute left-0.5 min-h-[26px] top-0 whitespace-nowrap text-text-placeholder ${
attributes.className ?? ''
}`;
}, [attributes.className]);

View File

@ -0,0 +1,12 @@
import React, { forwardRef } from 'react';
import { Alert } from '@mui/material';
export const UnSupportBlock = forwardRef<HTMLDivElement, void>((_, ref) => {
return (
<div ref={ref}>
<Alert className={'h-[48px] w-full'} title={'Unsupported block'} severity={'error'} />
</div>
);
});
export default UnSupportBlock;

View File

@ -6,10 +6,10 @@ export const BulletedList = memo(
({ node: _, children, className, ...attributes }, ref) => {
return (
<>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center pt-[3px] font-medium'}>
</span>
<div ref={ref} {...attributes} className={`${className} ml-6`}>
<div ref={ref} {...attributes} className={`${className} pl-6`}>
{children}
</div>
</>

View File

@ -18,13 +18,13 @@ export const GridBlock = memo(
}, [blockId, selectedBlockContext]);
return (
<div {...attributes} onClick={onClick} className={`${className} relative my-2`}>
<div {...attributes} onClick={onClick} className={`${className} relative my-2 w-full`}>
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
{children}
</div>
<div
contentEditable={false}
className='flex h-[400px] 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 border-line-divider bg-bg-body py-3 caret-text-title'
>
{viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />}
</div>

View File

@ -5,8 +5,8 @@ export const DividerNode = memo(
forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>(
({ node: _node, children: children, className, ...attributes }, ref) => {
return (
<div {...attributes} className={`${className} relative`}>
<div contentEditable={false} className={'w-full py-2.5 text-line-divider'}>
<div {...attributes} className={`${className} relative w-full`}>
<div contentEditable={false} className={'w-full py-3 text-line-divider'}>
<hr />
</div>
<div ref={ref} className={`absolute h-full w-full caret-transparent`}>

View File

@ -7,7 +7,7 @@ export const Heading = memo(
const level = node.data.level;
const fontSizeCssProperty = getHeadingCssProperty(level);
const className = `${attributes.className ?? ''} font-bold ${fontSizeCssProperty}`;
const className = `${attributes.className ?? ''} ${fontSizeCssProperty}`;
return (
<div {...attributes} ref={ref} className={className}>

View File

@ -20,7 +20,7 @@ export const MathEquation = memo(
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
className={`${className} relative my-2 cursor-pointer`}
className={`${className} relative my-2 w-full cursor-pointer`}
>
<div
contentEditable={false}

View File

@ -34,10 +34,10 @@ export const NumberedList = memo(
return (
<>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center font-medium'}>
<span contentEditable={false} className={'absolute flex w-6 select-none justify-center pt-[3px] font-medium'}>
{index}.
</span>
<div ref={ref} {...attributes} className={`${className} ml-6`}>
<div ref={ref} {...attributes} className={`${className} pl-6`}>
{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 ?? ''} text-4xl font-bold`;
return `${attributes.className ?? ''} pb-3 min-h-[52px] text-4xl font-bold`;
}, [attributes.className]);
return (

View File

@ -4,12 +4,18 @@ import { EditorElementProps, TextNode } from '$app/application/document/document
import { useSlateStatic } from 'slate-react';
export const Text = memo(
forwardRef<HTMLDivElement, EditorElementProps<TextNode>>(({ node, children, ...attributes }, ref) => {
forwardRef<HTMLDivElement, EditorElementProps<TextNode>>(({ node, children, className, ...attributes }, ref) => {
const editor = useSlateStatic();
const isEmpty = editor.isEmpty(node);
return (
<div ref={ref} {...attributes} className={`text-element mx-1 ${!isEmpty ? 'flex' : ''} relative h-full`}>
<div
ref={ref}
{...attributes}
className={`text-element min-h-[26px] px-1 ${!isEmpty ? 'flex items-center' : 'select-none leading-[26px]'} ${
className ?? ''
} relative h-full`}
>
<Placeholder isEmpty={isEmpty} node={node} />
<span>{children}</span>
</div>

View File

@ -22,7 +22,7 @@ export const TodoList = memo(
data-playwright-selected={false}
contentEditable={false}
onClick={toggleTodo}
className='absolute cursor-pointer select-none text-xl text-fill-default'
className='absolute cursor-pointer select-none pt-[3px] text-xl text-fill-default'
>
{checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />}
</span>

View File

@ -21,7 +21,7 @@ export const ToggleList = memo(
data-playwright-selected={false}
contentEditable={false}
onClick={toggleToggleList}
className='absolute cursor-pointer select-none text-xl hover:text-fill-default'
className='absolute cursor-pointer select-none pt-[3px] text-xl hover:text-fill-default'
>
{collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />}
</span>

View File

@ -1,7 +1,7 @@
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
import { EditorNodeType, CodeNode } from '$app/application/document/document.types';
import { createEditor, NodeEntry, BaseRange, Editor, Element } from 'slate';
import { createEditor, NodeEntry, BaseRange, Editor, Element, Range } 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';
@ -11,6 +11,7 @@ import { withYjs, YjsEditor, withYHistory } 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';
export function useEditor(sharedType: Y.XmlText) {
const editor = useMemo(() => {
@ -57,7 +58,7 @@ export function useEditor(sharedType: Y.XmlText) {
};
}
export function useDecorate(editor: ReactEditor) {
export function useDecorateCodeHighlight(editor: ReactEditor) {
return useCallback(
(entry: NodeEntry): BaseRange[] => {
const path = entry[1];
@ -80,9 +81,19 @@ export function useDecorate(editor: ReactEditor) {
export function useEditorState(editor: ReactEditor) {
const selectedBlocks = useMemo(() => proxySet([]), []);
const decorateState = useMemo(
() =>
proxySet<{
range: BaseRange;
class_name: string;
}>([]),
[]
);
const [selectedLength, setSelectedLength] = useState(0);
const ranges = useSnapshot(decorateState);
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
useEffect(() => {
@ -127,11 +138,44 @@ export function useEditorState(editor: ReactEditor) {
};
}, [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]
);
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,7 +1,8 @@
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import {
DecorateStateProvider,
EditorSelectedBlockProvider,
useDecorate,
useDecorateCodeHighlight,
useEditor,
useEditorState,
} from '$app/components/editor/components/editor/Editor.hooks';
@ -14,12 +15,23 @@ import { SlashCommandPanel } from '$app/components/editor/components/tools/comma
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';
function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
const decorate = useDecorate(editor);
const decorateCodeHighlight = useDecorateCodeHighlight(editor);
const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
const { selectedBlocks } = useEditorState(editor);
const { selectedBlocks, decorate: decorateCustomRange, decorateState } = useEditorState(editor);
const decorate = useCallback(
(entry: NodeEntry) => {
const codeRanges = decorateCodeHighlight(entry);
const customRanges = decorateCustomRange(entry);
return [...codeRanges, ...customRanges];
},
[decorateCodeHighlight, decorateCustomRange]
);
if (editor.sharedRoot.length === 0) {
return <CircularProgress className='m-auto' />;
@ -27,20 +39,22 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) {
return (
<EditorSelectedBlockProvider value={selectedBlocks}>
<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>
<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>
</DecorateStateProvider>
</EditorSelectedBlockProvider>
);
}

View File

@ -24,6 +24,7 @@ import { MathEquation } from '$app/components/editor/components/blocks/math_equa
import { Text as TextComponent } from '../blocks/text';
import { Page } from '../blocks/page';
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock';
function Element({ element, attributes, children }: RenderElementProps) {
const node = element;
@ -68,15 +69,24 @@ function Element({ element, attributes, children }: RenderElementProps) {
case EditorNodeType.EquationBlock:
return MathEquation;
default:
return Paragraph;
return UnSupportBlock;
}
}, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>;
const { isSelected } = useElementState(node);
const className = useMemo(() => {
return `block-element my-1 flex rounded ${isSelected ? 'bg-content-blue-100' : ''}`;
}, [isSelected]);
const align =
(
node.data as {
align: 'left' | 'center' | 'right';
}
)?.align || 'left';
return `block-element flex rounded ${isSelected ? 'bg-content-blue-100' : ''} ${
align ? `block-align-${align}` : ''
}`;
}, [isSelected, node.data]);
const style = useMemo(() => {
const data = (node.data as BlockData) || {};

View File

@ -5,11 +5,11 @@ import { Link } from '$app/components/editor/components/marks';
export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
let newChildren = children;
const classList = [leaf.prism_token, leaf.prism_token && 'token'].filter(Boolean);
const classList = [leaf.prism_token, leaf.prism_token && 'token', leaf.class_name].filter(Boolean);
if (leaf.code) {
newChildren = (
<code className={'mx-0.5 rounded-sm bg-gray-300 bg-opacity-50 px-1 text-xs font-normal text-[#EB5757]'}>
<code className={'bg-gray-300 bg-opacity-50 text-xs font-normal tracking-wider text-[#EB5757]'}>
{newChildren}
</code>
);

View File

@ -6,6 +6,7 @@ import React from 'react';
export const InlineChromiumBugfix = () => (
<span
contentEditable={false}
className={'absolute caret-transparent'}
style={{
fontSize: 0,
}}

View File

@ -46,6 +46,11 @@ function FormulaEditPopover({
placeholder={'E = mc^2'}
onChange={(e) => setText(e.target.value)}
fullWidth={true}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onDone(text);
}
}}
/>
<div className={'ml-2'}>
<Button size={'small'} variant={'text'} onClick={() => onDone(text)}>

View File

@ -1,10 +1,10 @@
import React from 'react';
import KatexMath from '$app/components/_shared/KatexMath';
function FormulaLeaf({ text, children }: { text: string; children: React.ReactNode }) {
function FormulaLeaf({ formula, children }: { formula: string; children: React.ReactNode }) {
return (
<span className={'relative'}>
<KatexMath latex={text || ''} isInline />
<KatexMath latex={formula || ''} isInline />
<span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span>
</span>
);

View File

@ -1,19 +1,17 @@
import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { Text, Transforms } from 'slate';
import { ReactEditor, useSelected, useSlate } from 'slate-react';
import { Transforms, Range, Editor } from 'slate';
import { EditorElementProps, FormulaNode } from '$app/application/document/document.types';
import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf';
import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix';
import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover';
import { getNodePath, moveCursorToNodeEnd } from '$app/components/editor/components/editor/utils';
import { useElementFocused } from '$app/components/editor/components/inline_nodes/useElementFocused';
import { CustomEditor } from '$app/components/editor/command';
export const InlineFormula = memo(
forwardRef<HTMLSpanElement, EditorElementProps<FormulaNode>>(({ node, children, ...attributes }, ref) => {
const editor = useSlate();
const text = (node.children[0] as Text).text;
const focused = useElementFocused(node);
const formula = node.data;
const selected = useSelected();
const anchor = useRef<HTMLSpanElement | null>(null);
const [openEditPopover, setOpenEditPopover] = useState<boolean>(false);
@ -31,12 +29,19 @@ export const InlineFormula = memo(
);
useEffect(() => {
if (focused) {
setOpenEditPopover(true);
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);
}
}, [focused]);
}, [editor, node, selected]);
const handleEditPopoverClose = useCallback(() => {
setOpenEditPopover(false);
@ -64,25 +69,35 @@ export const InlineFormula = memo(
contentEditable={false}
onDoubleClick={handleClick}
onClick={handleClick}
className={`relative rounded px-1 py-0.5 text-xs ${focused ? 'bg-fill-list-active' : ''}`}
data-playwright-selected={focused}
className={`relative rounded px-1 py-0.5 text-xs ${selected ? 'bg-fill-list-active' : ''}`}
data-playwright-selected={selected}
>
<InlineChromiumBugfix />
<FormulaLeaf text={text}>{children}</FormulaLeaf>
<InlineChromiumBugfix />
<FormulaLeaf formula={formula}>{children}</FormulaLeaf>
</span>
{openEditPopover && (
<FormulaEditPopover
defaultText={text}
onDone={(formula) => {
if (anchor.current === null) return;
defaultText={formula}
onDone={(newFormula) => {
if (anchor.current === null || newFormula === formula) return;
const path = getNodePath(editor, anchor.current);
// select the node before updating the formula
Transforms.select(editor, path);
CustomEditor.updateFormula(editor, formula);
if (newFormula === '') {
const point = editor.before(path);
handleEditPopoverClose();
CustomEditor.deleteFormula(editor);
setOpenEditPopover(false);
if (point) {
ReactEditor.focus(editor);
editor.select(point);
}
return;
} else {
CustomEditor.updateFormula(editor, newFormula);
handleEditPopoverClose();
}
}}
anchorEl={anchor.current}
open={openEditPopover}

View File

@ -2,25 +2,15 @@ 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 { useElementFocused } from '$app/components/editor/components/inline_nodes/useElementFocused';
export const Mention = memo(
forwardRef<HTMLSpanElement, EditorElementProps<MentionNode>>(({ node, children, ...attributes }, ref) => {
const focused = useElementFocused(node);
return (
<span
{...attributes}
data-playwright-selected={focused}
contentEditable={false}
className={`${attributes.className ?? ''} text-sx relative rounded px-1 hover:bg-content-blue-100`}
ref={ref}
style={{
backgroundColor: focused ? 'var(--content-blue-100)' : undefined,
}}
>
<MentionLeaf mention={node.data}>{children}</MentionLeaf>
</span>
<>
<span {...attributes} contentEditable={false} ref={ref}>
<MentionLeaf mention={node.data}>{children}</MentionLeaf>
</span>
</>
);
})
);

View File

@ -29,15 +29,18 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
}, [navigate, page]);
return (
<>
<span className={'relative'}>
{page && (
<span className={'inline-flex cursor-pointer items-center'} onClick={openPage}>
<span className={'mr-1 inline-flex items-center'}>{page.icon?.value || <DocumentSvg />}</span>
<span className={'text-sx underline'}>{page.name || t('document.title.placeholder')}</span>
<span
className={'relative inline-flex cursor-pointer items-center hover:bg-content-blue-100'}
onClick={openPage}
>
<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>
)}
<span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span>
</>
<span className={'invisible'}>{children}</span>
</span>
);
}

View File

@ -1,22 +0,0 @@
import { ReactEditor, useSelected, useSlate } from 'slate-react';
import { useMemo } from 'react';
import { Range, Text, Element } from 'slate';
export function useElementFocused(node: Element) {
const editor = useSlate();
const text = (node.children[0] as Text).text;
const selected = useSelected();
const focused = useMemo(() => {
if (!selected) return false;
const selection = editor.selection;
if (!selection) return false;
const path = ReactEditor.findPath(editor, node);
const range = { anchor: { path, offset: 0 }, focus: { path, offset: text.length } } as Range;
return Range.includes(range, selection);
}, [editor, selected, node, text.length]);
return focused;
}

View File

@ -5,7 +5,6 @@ import { PopoverCommonProps } from '$app/components/editor/components/tools/popo
import Button from '@mui/material/Button';
import { getNodePath } from '$app/components/editor/components/editor/utils';
import { addMark, BasePoint, Editor, Transforms, removeMark } from 'slate';
import { EditorStyleFormat } from '$app/application/document/document.types';
import { useSlate } from 'slate-react';
import { ReactComponent as RemoveSvg } from '$app/assets/delete.svg';
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
@ -13,6 +12,7 @@ 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.-]*)*\/?$/;
@ -37,10 +37,11 @@ export function LinkEditPopover({
// select the node before updating the formula
Transforms.select(editor, path);
if (link === '') {
removeMark(editor, EditorStyleFormat.Href);
removeMark(editor, EditorMarkFormat.Href);
} else {
addMark(editor, EditorStyleFormat.Href, link);
addMark(editor, EditorMarkFormat.Href, link);
}
onClose();
@ -54,7 +55,8 @@ export function LinkEditPopover({
// select the node before updating the formula
Transforms.select(editor, path);
editor.removeMark(EditorStyleFormat.Href);
editor.removeMark(EditorMarkFormat.Href);
onClose(beforePathEnd);
}, [editor, anchorEl, onClose]);

View File

@ -2,10 +2,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CustomColorPicker } from '$app/components/editor/components/tools/_shared/CustomColorPicker';
import Typography from '@mui/material/Typography';
import { MenuItem, MenuList } from '@mui/material';
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
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';
export interface ColorPickerProps {
onFocus?: () => void;
@ -14,63 +17,49 @@ export interface ColorPickerProps {
color?: string;
onChange?: (color: string) => void;
}
export function ColorPicker({ onFocus, onBlur, label, color, onChange }: ColorPickerProps) {
export function ColorPicker({ onFocus, onBlur, label, color = '', onChange }: ColorPickerProps) {
const { t } = useTranslation();
const colors = useMemo(
() => [
{
key: 'default',
name: t('colors.default'),
color: '',
},
{
key: 'gray',
name: t('colors.gray'),
color: '#78909c',
},
{
key: 'brown',
name: t('colors.brown'),
color: '#8d6e63',
},
{
key: 'orange',
name: t('colors.orange'),
color: '#ff9100',
},
{
key: 'yellow',
name: t('colors.yellow'),
color: '#ffd600',
},
{
key: 'green',
name: t('colors.green'),
color: '#00e676',
},
{
key: 'blue',
name: t('colors.blue'),
color: '#448aff',
},
{
key: 'purple',
name: t('colors.purple'),
color: '#e040fb',
},
{
key: 'pink',
name: t('colors.pink'),
color: '#ff4081',
},
{
key: 'red',
name: t('colors.red'),
color: '#ff5252',
},
],
[t]
);
const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
const [selectedColor, setSelectedColor] = useState(color);
useEffect(() => {
setSelectedColor(color);
}, [color]);
const handleColorChange = (color: string) => {
setSelectedColor(color);
onChange?.(color);
};
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]);
const [openCustomColorPicker, setOpenCustomColorPicker] = useState(false);
@ -97,16 +86,16 @@ export function ColorPicker({ onFocus, onBlur, label, color, onChange }: ColorPi
onMouseLeave={() => {
setOpenCustomColorPicker(false);
}}
className={'flex px-2 py-1'}
className={'mx-2 mb-2 flex px-2.5 py-1'}
ref={customItemRef}
>
<div className={'flex-1'}>{t('colors.custom')}</div>
<div className={'flex-1 text-xs'}>{t('colors.custom')}</div>
<MoreSvg className={'h-4 w-4'} />
{openCustomColorPicker && (
<CustomColorPicker
anchorEl={customItemRef.current}
open={openCustomColorPicker}
onColorChange={onChange}
onColorChange={handleColorChange}
onMouseDown={(e) => e.stopPropagation()}
{...PopoverNoBackdropProps}
onClose={() => {
@ -127,34 +116,39 @@ export function ColorPicker({ onFocus, onBlur, label, color, onChange }: ColorPi
/>
)}
</MenuItem>
{colors.map((c) => {
return (
<MenuItem
className={'flex px-2 py-1'}
key={c.key}
<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={() => {
onChange?.(c.color);
handleColorChange('');
}}
>
<div
className={'mr-2 flex h-4 w-4 items-center justify-center rounded-full border-2 p-0.5'}
<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={{
borderColor: c.color,
backgroundColor: c === selectedColor ? 'var(--content-blue-100)' : undefined,
}}
>
<div
className={'h-2 w-2 rounded-full'}
className={'h-full w-full rounded-full'}
style={{
backgroundColor: c.color,
backgroundColor: c,
}}
/>
</div>
<div className={'flex-1'}>{c.name}</div>
{color && color === c.color && <SelectCheckSvg />}
</MenuItem>
);
})}
</IconButton>
);
})}
</div>
</MenuList>
</div>
);

View File

@ -4,11 +4,11 @@ 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';
export function BlockActions({ node }: { node?: Element }) {
export function BlockActions({ node, setMenuVisible }: { node?: Element; setMenuVisible: (visible: boolean) => void }) {
return (
<>
<AddBlockBelow node={node} />
<BlockMenu node={node} />
<BlockMenu setMenuVisible={setMenuVisible} node={node} />
</>
);
}

View File

@ -7,6 +7,7 @@ import { EditorNodeType } from '$app/application/document/document.types';
export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
const editor = useSlate();
const [node, setNode] = useState<Element | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
@ -69,15 +70,22 @@ export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) {
const dom = ReactEditor.toDOMNode(editor, editor);
dom.addEventListener('mousemove', handleMouseMove);
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
if (!menuVisible) {
dom.addEventListener('mousemove', handleMouseMove);
dom.parentElement?.addEventListener('mouseleave', handleMouseLeave);
} else {
dom.removeEventListener('mousemove', handleMouseMove);
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
}
return () => {
dom.removeEventListener('mousemove', handleMouseMove);
dom.parentElement?.removeEventListener('mouseleave', handleMouseLeave);
};
}, [editor, ref]);
}, [editor, ref, menuVisible]);
return {
setMenuVisible,
node: node?.type === EditorNodeType.Page ? null : node,
};
}

View File

@ -7,7 +7,7 @@ import { getBlockCssProperty } from '$app/components/editor/components/tools/blo
export function BlockActionsToolbar() {
const ref = useRef<HTMLDivElement | null>(null);
const { node } = useBlockActionsToolbar(ref);
const { node, setMenuVisible } = useBlockActionsToolbar(ref);
const cssProperty = node && getBlockCssProperty(node);
@ -15,7 +15,7 @@ export function BlockActionsToolbar() {
<div
ref={ref}
contentEditable={false}
className={`block-actions ${cssProperty} absolute z-10 flex w-[64px] flex-grow transform items-center justify-end px-1 opacity-0 transition-opacity`}
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();
@ -26,8 +26,8 @@ export function BlockActionsToolbar() {
}}
>
{/* Ensure the toolbar in middle */}
<div className={'invisible'}>0</div>
{<BlockActions node={node || undefined} />}
<div className={`invisible`}>$</div>
{<BlockActions setMenuVisible={setMenuVisible} node={node || undefined} />}
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useContext, useRef, useState } from 'react';
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';
@ -6,13 +6,20 @@ import BlockOperationMenu from '$app/components/editor/components/tools/block_ac
import { Element } from 'slate';
import { EditorSelectedBlockContext } from '$app/components/editor/components/editor/Editor.hooks';
function BlockMenu({ node }: { node?: Element }) {
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();

View File

@ -1,35 +1,15 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
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 { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { useTranslation } from 'react-i18next';
import { Button, Divider, MenuProps, Menu } from '@mui/material';
import { Button, Divider } from '@mui/material';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { Element } from 'slate';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
import Typography from '@mui/material/Typography';
import { useBlockMenuKeyDown } from '$app/components/editor/components/tools/block_actions/BlockMenu.hooks';
enum SubMenuType {
TextColor = 'textColor',
BackgroundColor = 'backgroundColor',
}
const subMenuProps: Partial<MenuProps> = {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
};
import { Color } from './color';
export function BlockOperationMenu({
node,
@ -37,7 +17,6 @@ export function BlockOperationMenu({
}: {
node: Element;
} & PopoverProps) {
const optionsRef = React.useRef<HTMLDivElement>(null);
const editor = useSlateStatic();
const { t } = useTranslation();
@ -57,14 +36,6 @@ export function BlockOperationMenu({
const { onKeyDown } = useBlockMenuKeyDown({
onClose: handleClose,
});
const [subMenuType, setSubMenuType] = useState<null | SubMenuType>(null);
const subMenuAnchorEl = useMemo(() => {
if (!subMenuType) return null;
return optionsRef.current?.querySelector(`[data-submenu-type="${subMenuType}"]`);
}, [subMenuType]);
const subMenuOpen = Boolean(subMenuAnchorEl);
const operationOptions = useMemo(
() => [
@ -88,51 +59,6 @@ export function BlockOperationMenu({
[editor, node, handleClose, t]
);
const colorOptions = useMemo(
() => [
{
type: SubMenuType.TextColor,
text: t('editor.textColor'),
onClick: () => {
setSubMenuType(SubMenuType.TextColor);
},
},
{
type: SubMenuType.BackgroundColor,
text: t('editor.backgroundColor'),
onClick: () => {
setSubMenuType(SubMenuType.BackgroundColor);
},
},
],
[t]
);
const subMenuContent = useMemo(() => {
switch (subMenuType) {
case SubMenuType.TextColor:
return (
<FontColorPicker
onChange={(color) => {
CustomEditor.setBlockColor(editor, node, { font_color: color });
handleClose();
}}
/>
);
case SubMenuType.BackgroundColor:
return (
<BgColorPicker
onChange={(color) => {
CustomEditor.setBlockColor(editor, node, { bg_color: color });
handleClose();
}}
/>
);
default:
return null;
}
}, [editor, node, handleClose, subMenuType]);
return (
<Popover
{...PopoverCommonProps}
@ -157,34 +83,17 @@ export function BlockOperationMenu({
))}
</div>
<Divider className={'my-1'} />
<div ref={optionsRef} className={'flex flex-col p-2'}>
<Typography variant={'body2'} className={'mb-1 text-text-caption'}>
{t('editor.color')}
</Typography>
{colorOptions.map((option, index) => (
<Button
data-submenu-type={option.type}
color={'inherit'}
onClick={option.onClick}
size={'small'}
endIcon={<MoreSvg />}
className={'w-full justify-between'}
key={index}
>
<div className={'flex-1 text-left'}>{option.text}</div>
</Button>
))}
</div>
<Menu
container={optionsRef.current}
{...PopoverCommonProps}
{...subMenuProps}
open={subMenuOpen}
anchorEl={subMenuAnchorEl}
onClose={() => setSubMenuType(null)}
>
{subMenuContent}
</Menu>
<Color
node={
node as Element & {
data?: {
font_color?: string;
bg_color?: string;
};
}
}
onClose={handleClose}
/>
</Popover>
);
}

View File

@ -0,0 +1,100 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Element } from 'slate';
import { Button, 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 { 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';
export function Color({
node,
onClose,
}: {
node: Element & {
data?: {
font_color?: string;
bg_color?: string;
};
};
onClose?: () => 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 onColorChange = useCallback(
(format: 'font_color' | 'bg_color', color: string) => {
CustomEditor.setBlockColor(editor, node, {
[format]: color,
});
onClose?.();
},
[editor, node, onClose]
);
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 && (
<Popover
{...PopoverNoBackdropProps}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
PaperProps={{
...PopoverNoBackdropProps.PaperProps,
className: 'w-[200px] max-h-[360px] overflow-x-hidden overflow-y-auto',
}}
open={open}
anchorEl={ref.current}
onClose={() => {
setOpen(false);
}}
>
<div className={'flex flex-col'}>
<FontColorPicker
onChange={(color) => {
onColorChange('font_color', color);
}}
color={fontColor}
/>
<BgColorPicker
onChange={(color) => {
onColorChange('bg_color', color);
}}
color={bgColor}
/>
</div>
</Popover>
)}
</Button>
);
}

View File

@ -18,7 +18,7 @@ export function useMentionPanel({
const onClick = useCallback(
(type: MentionType, mention: Mention) => {
closePanel(false);
closePanel(true);
CustomEditor.insertMention(editor, mention);
},
[closePanel, editor]

View File

@ -1,485 +0,0 @@
import {
EditorInlineAttributes,
EditorInlineNodeType,
EditorMarkFormat,
EditorNodeType,
EditorStyleFormat,
EditorTurnFormat,
HeadingNode,
} from '$app/application/document/document.types';
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
import { ReactComponent as Heading1Svg } from '$app/assets/h1.svg';
import { ReactComponent as Heading2Svg } from '$app/assets/h2.svg';
import { ReactComponent as Heading3Svg } from '$app/assets/h3.svg';
import { ReactComponent as ParagraphSvg } from '$app/assets/text.svg';
import { ReactComponent as TodoListSvg } from '$app/assets/todo-list.svg';
import { ReactComponent as QuoteSvg } from '$app/assets/quote.svg';
import { ReactComponent as ToggleListSvg } from '$app/assets/show-menu.svg';
import { ReactComponent as NumberedListSvg } from '$app/assets/numbers.svg';
import { ReactComponent as BulletedListSvg } from '$app/assets/list.svg';
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
import FormatColorFillIcon from '@mui/icons-material/FormatColorFill';
import FormatColorTextIcon from '@mui/icons-material/FormatColorText';
import Functions from '@mui/icons-material/Functions';
import { ReactEditor } from 'slate-react';
import React, { useCallback, useMemo } from 'react';
import { getBlock } from '$app/components/editor/plugins/utils';
import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/_shared';
import { useTranslation } from 'react-i18next';
import { addMark, Editor } from 'slate';
import { CustomEditor } from '$app/components/editor/command';
const markFormatActions = [
EditorMarkFormat.Underline,
EditorMarkFormat.Bold,
EditorMarkFormat.Italic,
EditorMarkFormat.StrikeThrough,
EditorMarkFormat.Code,
EditorMarkFormat.Formula,
];
const styleFormatActions = [EditorStyleFormat.Href, EditorStyleFormat.FontColor, EditorStyleFormat.BackgroundColor];
const textFormatActions = [
EditorTurnFormat.Paragraph,
EditorTurnFormat.Heading1,
EditorTurnFormat.Heading2,
EditorTurnFormat.Heading3,
];
const blockFormatActions = [
EditorTurnFormat.TodoList,
EditorTurnFormat.Quote,
EditorTurnFormat.ToggleList,
EditorTurnFormat.NumberedList,
EditorTurnFormat.BulletedList,
];
export interface SelectionAction {
format: EditorMarkFormat | EditorTurnFormat | EditorStyleFormat;
Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
text: string;
isActive: () => boolean;
onClick: ((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void) | (() => void);
alwaysInSingleLine?: boolean;
}
export function useSelectionMarkFormatActions(editor: ReactEditor) {
const { t } = useTranslation();
const formatMark = useCallback(
(format: EditorMarkFormat) => {
CustomEditor.toggleMark(editor, {
key: format,
value: true,
});
},
[editor]
);
const isFormatActive = useCallback(
(format: EditorMarkFormat) => {
return CustomEditor.isMarkActive(editor, format);
},
[editor]
);
return useMemo(() => {
const map = {
[EditorMarkFormat.Bold]: {
format: EditorMarkFormat.Bold,
Icon: BoldSvg,
text: t('editor.bold'),
isActive: () => {
return isFormatActive(EditorMarkFormat.Bold);
},
onClick: () => {
formatMark(EditorMarkFormat.Bold);
},
},
[EditorMarkFormat.Italic]: {
format: EditorMarkFormat.Italic,
Icon: ItalicSvg,
text: t('editor.italic'),
isActive: () => {
return isFormatActive(EditorMarkFormat.Italic);
},
onClick: () => {
formatMark(EditorMarkFormat.Italic);
},
},
[EditorMarkFormat.Underline]: {
format: EditorMarkFormat.Underline,
Icon: UnderlineSvg,
text: t('editor.underline'),
isActive: () => {
return isFormatActive(EditorMarkFormat.Underline);
},
onClick: () => {
formatMark(EditorMarkFormat.Underline);
},
},
[EditorMarkFormat.StrikeThrough]: {
format: EditorMarkFormat.StrikeThrough,
Icon: StrikeThroughSvg,
text: t('editor.strikethrough'),
isActive: () => {
return isFormatActive(EditorMarkFormat.StrikeThrough);
},
onClick: () => {
formatMark(EditorMarkFormat.StrikeThrough);
},
},
[EditorMarkFormat.Code]: {
format: EditorMarkFormat.Code,
Icon: CodeSvg,
text: t('editor.embedCode'),
isActive: () => {
return isFormatActive(EditorMarkFormat.Code);
},
onClick: () => {
formatMark(EditorMarkFormat.Code);
},
},
[EditorMarkFormat.Formula]: {
format: EditorMarkFormat.Formula,
Icon: Functions,
alwaysInSingleLine: true,
text: t('document.plugins.createInlineMathEquation'),
isActive: () => {
return CustomEditor.isFormulaActive(editor);
},
onClick: () => {
CustomEditor.toggleInlineElement(editor, EditorInlineNodeType.Formula);
},
},
};
return markFormatActions.map((format) => map[format]) as SelectionAction[];
}, [editor, formatMark, isFormatActive, t]);
}
export function useBlockFormatActionMap(editor: ReactEditor) {
const { t } = useTranslation();
return useMemo(() => {
const toHeading = (level: number) => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.HeadingBlock,
data: {
level,
},
});
};
return {
[EditorTurnFormat.Paragraph]: {
format: EditorTurnFormat.Paragraph,
text: t('editor.text'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.Paragraph,
});
},
Icon: ParagraphSvg,
isActive: () => {
return CustomEditor.isBlockActive(editor, EditorTurnFormat.Paragraph);
},
},
[EditorTurnFormat.Heading1]: {
format: EditorTurnFormat.Heading1,
text: t('editor.heading1'),
Icon: Heading1Svg,
onClick: () => {
toHeading(1);
},
isActive: () => {
const node = getBlock(editor) as HeadingNode;
if (!node) return false;
const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock);
return isBlock && node.data.level === 1;
},
},
[EditorTurnFormat.Heading2]: {
format: EditorTurnFormat.Heading2,
Icon: Heading2Svg,
text: t('editor.heading2'),
onClick: () => {
toHeading(2);
},
isActive: () => {
const node = getBlock(editor) as HeadingNode;
if (!node) return false;
const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock);
return isBlock && node.data.level === 2;
},
},
[EditorTurnFormat.Heading3]: {
format: EditorTurnFormat.Heading3,
Icon: Heading3Svg,
text: t('editor.heading3'),
onClick: () => {
toHeading(3);
},
isActive: () => {
const node = getBlock(editor) as HeadingNode;
if (!node) return false;
const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock);
return isBlock && node.data.level === 3;
},
},
[EditorTurnFormat.TodoList]: {
format: EditorTurnFormat.TodoList,
text: t('document.plugins.todoList'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.TodoListBlock,
});
},
Icon: TodoListSvg,
isActive: () => {
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
const node = entry[0];
return node.type === EditorNodeType.TodoListBlock;
},
},
[EditorTurnFormat.Quote]: {
format: EditorTurnFormat.Quote,
text: t('editor.quote'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.QuoteBlock,
});
},
Icon: QuoteSvg,
isActive: () => {
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
const node = entry[0];
return node.type === EditorNodeType.QuoteBlock;
},
},
[EditorTurnFormat.ToggleList]: {
format: EditorTurnFormat.ToggleList,
text: t('document.plugins.toggleList'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.ToggleListBlock,
});
},
Icon: ToggleListSvg,
isActive: () => {
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
const node = entry[0];
return node.type === EditorNodeType.ToggleListBlock;
},
},
[EditorTurnFormat.NumberedList]: {
format: EditorTurnFormat.NumberedList,
text: t('document.plugins.numberedList'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.NumberedListBlock,
});
},
Icon: NumberedListSvg,
isActive: () => {
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
const node = entry[0];
return node.type === EditorNodeType.NumberedListBlock;
},
},
[EditorTurnFormat.BulletedList]: {
format: EditorTurnFormat.BulletedList,
text: t('document.plugins.bulletedList'),
onClick: () => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.BulletedListBlock,
});
},
Icon: BulletedListSvg,
isActive: () => {
const entry = CustomEditor.getBlock(editor);
if (!entry) return false;
const node = entry[0];
return node.type === EditorNodeType.BulletedListBlock;
},
},
};
}, [editor, t]);
}
export function useSelectionTextFormatActions(editor: ReactEditor): SelectionAction[] {
const map = useBlockFormatActionMap(editor);
return useMemo(() => {
return textFormatActions.map((action) => map[action]);
}, [map]);
}
export function useBlockFormatActions(editor: ReactEditor): SelectionAction[] {
const map = useBlockFormatActionMap(editor);
return useMemo(() => {
return blockFormatActions.map((action) => map[action]);
}, [map]);
}
export function useSelectionStyleFormatActions(
editor: ReactEditor,
{
onPopoverOpen,
onFocus,
onBlur,
onPopoverClose,
}: {
onPopoverOpen: (format: EditorStyleFormat, target: HTMLButtonElement) => void;
onPopoverClose: () => void;
onFocus: () => void;
onBlur: () => void;
}
) {
const handleStyleChange = useCallback(
(format: EditorStyleFormat, value: string) => {
onPopoverClose();
addMark(editor, format, value);
},
[editor, onPopoverClose]
);
const subMenu = useCallback(
(format: EditorStyleFormat) => {
if (!editor.selection) return null;
const entry = editor.node(editor.selection);
const node = entry[0] as EditorInlineAttributes;
switch (format) {
case EditorStyleFormat.Href:
return null;
case EditorStyleFormat.BackgroundColor:
return (
<BgColorPicker
onBlur={onBlur}
onFocus={onFocus}
color={node.bg_color}
onChange={(color) => handleStyleChange(format, color)}
/>
);
case EditorStyleFormat.FontColor:
return (
<FontColorPicker
onBlur={onBlur}
onFocus={onFocus}
color={node.font_color}
onChange={(color) => handleStyleChange(format, color)}
/>
);
}
},
[editor, handleStyleChange, onBlur, onFocus]
);
const { t } = useTranslation();
const options = useMemo(() => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>, format: EditorStyleFormat) => {
onPopoverOpen(format, e.currentTarget);
};
const map = {
[EditorStyleFormat.Href]: {
format: EditorStyleFormat.Href,
Icon: LinkSvg,
text: t('editor.link'),
alwaysInSingleLine: true,
isActive: () => {
return CustomEditor.isMarkActive(editor, EditorStyleFormat.Href);
},
onClick: () => {
if (!editor.selection) return;
const text = Editor.string(editor, editor.selection);
addMark(editor, EditorStyleFormat.Href, text);
},
},
[EditorStyleFormat.FontColor]: {
format: EditorStyleFormat.FontColor,
Icon: FormatColorTextIcon,
text: t('editor.textColor'),
isActive: () => {
return CustomEditor.isMarkActive(editor, EditorStyleFormat.FontColor);
},
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => handleClick(e, EditorStyleFormat.FontColor),
},
[EditorStyleFormat.BackgroundColor]: {
format: EditorStyleFormat.BackgroundColor,
Icon: FormatColorFillIcon,
text: t('editor.backgroundColor'),
isActive: () => {
return CustomEditor.isMarkActive(editor, EditorStyleFormat.BackgroundColor);
},
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) =>
handleClick(e, EditorStyleFormat.BackgroundColor),
},
};
return styleFormatActions.map((format) => map[format]) as SelectionAction[];
}, [t, editor, onPopoverOpen]);
return {
options,
handleStyleChange,
subMenu,
};
}

View File

@ -1,56 +1,34 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import IconButton from '@mui/material/IconButton';
import React, { useMemo } from 'react';
import { useSlate } from 'slate-react';
import { Range } from 'slate';
import {
SelectionAction,
useBlockFormatActions,
useSelectionMarkFormatActions,
useSelectionStyleFormatActions,
useSelectionTextFormatActions,
} from '$app/components/editor/components/tools/selection_toolbar/SelectionActions.hooks';
import Popover from '@mui/material/Popover';
import { EditorStyleFormat } from '$app/application/document/document.types';
import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover';
import { Tooltip } from '@mui/material';
import { CustomEditor } from '$app/components/editor/command';
import { Paragraph } from '$app/components/editor/components/tools/selection_toolbar/actions/paragraph';
import { Heading } from '$app/components/editor/components/tools/selection_toolbar/actions/heading';
import { Divider } from '@mui/material';
import { Bold } from '$app/components/editor/components/tools/selection_toolbar/actions/bold';
import { Italic } from '$app/components/editor/components/tools/selection_toolbar/actions/italic';
import { Underline } from '$app/components/editor/components/tools/selection_toolbar/actions/underline';
import { StrikeThrough } from '$app/components/editor/components/tools/selection_toolbar/actions/strikethrough';
import { InlineCode } from '$app/components/editor/components/tools/selection_toolbar/actions/inline_code';
import { Formula } from '$app/components/editor/components/tools/selection_toolbar/actions/formula';
import { TodoList } from '$app/components/editor/components/tools/selection_toolbar/actions/todo_list';
import { Quote } from '$app/components/editor/components/tools/selection_toolbar/actions/quote';
import { ToggleList } from '$app/components/editor/components/tools/selection_toolbar/actions/toggle_list';
import { BulletedList } from '$app/components/editor/components/tools/selection_toolbar/actions/bulleted_list';
import { NumberedList } from '$app/components/editor/components/tools/selection_toolbar/actions/numbered_list';
import { Href } from '$app/components/editor/components/tools/selection_toolbar/actions/href';
import { Align } from '$app/components/editor/components/tools/selection_toolbar/actions/align';
import { Color } from '$app/components/editor/components/tools/selection_toolbar/actions/color';
function SelectionActions({
toolbarVisible,
storeSelection,
restoreSelection,
}: {
toolbarVisible: boolean;
storeSelection: () => void;
restoreSelection: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const editor = useSlate() as ReactEditor;
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [menuType, setMenuType] = useState<EditorStyleFormat | null>(null);
const open = Boolean(anchorEl);
const handlePopoverOpen = useCallback((format: EditorStyleFormat, target: HTMLButtonElement) => {
setAnchorEl(target);
setMenuType(format);
}, []);
const handleFocus = useCallback(() => {
storeSelection();
}, [storeSelection]);
const handleBlur = useCallback(() => {
restoreSelection();
}, [restoreSelection]);
const handlePopoverClose = useCallback(() => {
setAnchorEl(null);
setMenuType(null);
handleBlur();
}, [handleBlur]);
const [isMultiple, setIsMultiple] = useState(false);
const getIsMultiple = useCallback(() => {
const editor = useSlate();
const isAcrossBlockSelection = useMemo(() => {
if (!editor.selection) return false;
const selection = editor.selection;
const start = selection.anchor;
@ -65,100 +43,43 @@ function SelectionActions({
const endNode = CustomEditor.getBlock(editor, end);
return Boolean(startNode && endNode && startNode[0].blockId !== endNode[0].blockId);
}, [editor]);
useEffect(() => {
if (toolbarVisible) {
setIsMultiple(getIsMultiple());
} else {
setIsMultiple(false);
}
}, [editor, getIsMultiple, toolbarVisible]);
const markOptions = useSelectionMarkFormatActions(editor);
const textOptions = useSelectionTextFormatActions(editor);
const blockOptions = useBlockFormatActions(editor);
const { options: styleOptions, subMenu: styleSubMenu } = useSelectionStyleFormatActions(editor, {
onPopoverOpen: handlePopoverOpen,
onPopoverClose: handlePopoverClose,
onFocus: handleFocus,
onBlur: handleBlur,
});
const subMenu = useMemo(() => {
if (!menuType) return null;
return styleSubMenu(menuType);
}, [menuType, styleSubMenu]);
const group = useMemo(() => {
const base = [markOptions, styleOptions];
if (isMultiple) {
const filter = (option: SelectionAction) => {
return !option.alwaysInSingleLine;
};
return [markOptions.filter(filter), styleOptions.filter(filter)];
}
return [textOptions, ...base, blockOptions];
}, [markOptions, styleOptions, isMultiple, textOptions, blockOptions]);
useEffect(() => {
if (!toolbarVisible) {
handlePopoverClose();
}
}, [toolbarVisible, handlePopoverClose]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor.selection, editor]);
return (
<div ref={ref} className={'flex w-fit flex-grow items-center'}>
{group.map((item, index) => {
return (
<div key={index} className={index > 0 ? 'border-l border-gray-500' : ''}>
{item.map((action) => {
const { format, Icon, text, onClick, isActive } = action;
const isActivated = isActive();
return (
<Tooltip placement={'top'} title={text} key={format}>
<IconButton
onClick={onClick}
size={'small'}
className={`bg-transparent px-1.5 py-0 text-bg-body hover:bg-transparent`}
>
<Icon
style={{
color: isActivated ? 'var(--fill-default)' : undefined,
}}
className={'h-4 w-4 text-lg text-bg-body hover:text-fill-hover'}
/>
</IconButton>
</Tooltip>
);
})}
</div>
);
})}
{open && (
<Popover
{...PopoverPreventBlurProps}
anchorEl={anchorEl}
open={open}
onClose={handlePopoverClose}
anchorOrigin={{
vertical: 30,
horizontal: 'left',
}}
onMouseUp={(e) => {
// prevent editor blur
e.stopPropagation();
}}
>
{subMenu}
</Popover>
<div className={'flex w-fit flex-grow items-center gap-1'}>
{!isAcrossBlockSelection && (
<>
<Paragraph />
<Heading />
<Divider className={'opacity-40'} orientation={'vertical'} flexItem={true} />
</>
)}
<Bold />
<Italic />
<Underline />
<StrikeThrough />
<InlineCode />
{!isAcrossBlockSelection && (
<>
<Formula />
<Divider className={'opacity-40'} orientation={'vertical'} flexItem={true} />
</>
)}
{!isAcrossBlockSelection && (
<>
<TodoList />
<Quote />
<ToggleList />
<BulletedList />
<NumberedList />
<Divider className={'opacity-40'} orientation={'vertical'} flexItem={true} />
</>
)}
{!isAcrossBlockSelection && <Href />}
<Align />
<Color onOpen={storeSelection} onClose={restoreSelection} />
</div>
);
}

View File

@ -1,8 +1,10 @@
import { ReactEditor, useSlate } from 'slate-react';
import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { MutableRefObject, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils';
import debounce from 'lodash-es/debounce';
import { CustomEditor } from '$app/components/editor/command';
import { DecorateStateContext } from '$app/components/editor/components/editor/Editor.hooks';
import { BaseRange } from 'slate';
export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>) {
const editor = useSlate() as ReactEditor;
@ -82,6 +84,8 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
};
}, [editor, recalculatePosition, ref]);
const decorateStateContext = useContext(DecorateStateContext);
const restoreSelection = useCallback(() => {
if (!rangeRef.current) return;
const windowSelection = window.getSelection();
@ -90,9 +94,14 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
windowSelection.removeAllRanges();
windowSelection.addRange(rangeRef.current);
rangeRef.current = null;
}, []);
decorateStateContext.clear();
}, [decorateStateContext]);
const storeSelection = useCallback(() => {
decorateStateContext.add({
range: editor.selection as BaseRange,
class_name: 'bg-content-blue-100',
});
const windowSelection = window.getSelection();
if (!windowSelection) return;
@ -100,7 +109,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
if (windowSelection.rangeCount === 0) return;
rangeRef.current = windowSelection.getRangeAt(0);
}, []);
}, [decorateStateContext, editor.selection]);
return {
visible,

View File

@ -5,13 +5,13 @@ import SelectionActions from '$app/components/editor/components/tools/selection_
export const SelectionToolbar = memo(() => {
const ref = useRef<HTMLDivElement | null>(null);
const { visible, ...toolbarProps } = useSelectionToolbar(ref);
const { visible, restoreSelection, storeSelection } = useSelectionToolbar(ref);
return (
<div
ref={ref}
className={
'selection-toolbar pointer-events-none absolute z-[100] flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity'
'selection-toolbar pointer-events-none absolute z-[100] flex min-h-[37px] w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] px-2 opacity-0 shadow-lg transition-opacity'
}
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
@ -21,7 +21,11 @@ export const SelectionToolbar = memo(() => {
e.stopPropagation();
}}
>
<SelectionActions {...toolbarProps} toolbarVisible={visible} />
{visible ? (
<SelectionActions storeSelection={storeSelection} restoreSelection={restoreSelection} />
) : (
<div className={'w-[541px]'} />
)}
</div>
);
});

View File

@ -0,0 +1,32 @@
import React, { forwardRef } from 'react';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import { Tooltip } from '@mui/material';
const ActionButton = forwardRef<
HTMLButtonElement,
{
tooltip: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
active?: boolean;
} & IconButtonProps
>(({ tooltip, onClick, children, active, className, ...props }, ref) => {
return (
<Tooltip placement={'top'} title={tooltip}>
<IconButton
ref={ref}
onClick={onClick}
size={'small'}
style={{
color: active ? 'var(--fill-default)' : undefined,
}}
{...props}
className={`${className ?? ''} bg-transparent px-1 py-2 text-bg-body hover:bg-transparent hover:text-fill-hover`}
>
{children}
</IconButton>
</Tooltip>
);
});
export default ActionButton;

View File

@ -0,0 +1,96 @@
import React, { useCallback, useMemo } from 'react';
import Tooltip from '@mui/material/Tooltip';
import { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.svg';
import { ReactComponent as AlignCenterSvg } from '$app/assets/align-center.svg';
import { ReactComponent as AlignRightSvg } from '$app/assets/align-right.svg';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import { IconButton } from '@mui/material';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
export function Align() {
const { t } = useTranslation();
const editor = useSlateStatic();
const align = CustomEditor.getAlign(editor);
const [open, setOpen] = React.useState(false);
const handleClose = () => {
setOpen(false);
};
const handleOpen = () => {
setOpen(true);
};
const Icon = useMemo(() => {
switch (align) {
case 'left':
return AlignLeftSvg;
case 'center':
return AlignCenterSvg;
case 'right':
return AlignRightSvg;
default:
return AlignLeftSvg;
}
}, [align]);
const toggleAlign = useCallback(
(align: string) => {
return () => {
CustomEditor.toggleAlign(editor, align);
};
},
[editor]
);
const getAlignIcon = useCallback((key: string) => {
switch (key) {
case 'left':
return <AlignLeftSvg />;
case 'center':
return <AlignCenterSvg />;
case 'right':
return <AlignRightSvg />;
default:
return <AlignLeftSvg />;
}
}, []);
return (
<Tooltip
placement={'bottom'}
open={open}
onClose={handleClose}
onOpen={handleOpen}
classes={{ tooltip: 'bg-fill-toolbar' }}
title={
<div className={'flex items-center justify-center'}>
{['left', 'center', 'right'].map((key) => {
return (
<IconButton
key={key}
className={'text-content-on-fill hover:bg-transparent hover:text-content-blue-400'}
onClick={toggleAlign(key)}
disabled={align === key}
>
{getAlignIcon(key)}
</IconButton>
);
})}
</div>
}
>
<ActionButton active={!!align} tooltip={t('document.plugins.optionAction.align')}>
<div className={'flex items-center'}>
<Icon />
<MoreSvg className={'rotate-90 transform'} />
</div>
</ActionButton>
</Tooltip>
);
}
export default Align;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function Bold() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Bold,
value: true,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.bold')}>
<BoldSvg />
</ActionButton>
);
}
export default Bold;

View File

@ -0,0 +1,27 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { ReactComponent as BulletedListSvg } from '$app/assets/list.svg';
export function BulletedList() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.BulletedListBlock);
const onClick = useCallback(() => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.BulletedListBlock,
});
}, [editor]);
return (
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.bulletedList')}>
<BulletedListSvg />
</ActionButton>
);
}
export default BulletedList;

View File

@ -0,0 +1,95 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { CustomEditor } from '$app/components/editor/command';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import ColorLensOutlinedIcon from '@mui/icons-material/ColorLensOutlined';
import { addMark } from 'slate';
import { Popover } from '@mui/material';
import { BgColorPicker, FontColorPicker } from '$app/components/editor/components/tools/_shared';
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
import { PopoverNoBackdropProps } from '$app/components/editor/components/tools/popover';
export function Color({ onClose, onOpen }: { onClose?: () => void; onOpen?: () => void }) {
const { t } = useTranslation();
const editor = useSlateStatic();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
}, [onClose, onOpen, open]);
const isActivated =
CustomEditor.isMarkActive(editor, EditorMarkFormat.FontColor) ||
CustomEditor.isMarkActive(editor, EditorMarkFormat.BgColor);
const handleChange = useCallback(
(format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => {
addMark(editor, format, color);
},
[editor]
);
const fontColor = useMemo(() => {
return editor.getMarks()?.[EditorMarkFormat.FontColor];
}, [editor]);
const bgColor = useMemo(() => {
return editor.getMarks()?.[EditorMarkFormat.BgColor];
}, [editor]);
return (
<>
<ActionButton
ref={ref}
onMouseEnter={() => {
setOpen(true);
}}
onMouseLeave={() => {
setOpen(false);
}}
tooltip={t('editor.color')}
active={isActivated}
>
<div className={'flex items-center justify-between'}>
<ColorLensOutlinedIcon className={'w-[14px]'} />
<MoreSvg className={'rotate-90 transform'} />
</div>
{open && (
<Popover
{...PopoverNoBackdropProps}
open={open}
anchorEl={ref.current}
onClose={() => {
setOpen(false);
}}
PaperProps={{
...PopoverNoBackdropProps.PaperProps,
className: 'w-[200px] max-h-[360px] overflow-x-hidden overflow-y-auto',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
disableAutoFocus={false}
onKeyDown={(e) => {
e.stopPropagation();
}}
>
<FontColorPicker color={fontColor} onChange={(color) => handleChange(EditorMarkFormat.FontColor, color)} />
<BgColorPicker color={bgColor} onChange={(color) => handleChange(EditorMarkFormat.BgColor, color)} />
</Popover>
)}
</ActionButton>
</>
);
}

View File

@ -0,0 +1,24 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import Functions from '@mui/icons-material/Functions';
export function Formula() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isFormulaActive(editor);
const onClick = useCallback(() => {
CustomEditor.toggleFormula(editor);
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('document.plugins.createInlineMathEquation')}>
<Functions className={'w-[14px]'} />
</ActionButton>
);
}
export default Formula;

View File

@ -0,0 +1,56 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { ReactComponent as Heading1Svg } from '$app/assets/h1.svg';
import { ReactComponent as Heading2Svg } from '$app/assets/h2.svg';
import { ReactComponent as Heading3Svg } from '$app/assets/h3.svg';
import { useTranslation } from 'react-i18next';
import { CustomEditor } from '$app/components/editor/command';
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
import { useSlateStatic } from 'slate-react';
import { getBlock } from '$app/components/editor/plugins/utils';
export function Heading() {
const { t } = useTranslation();
const editor = useSlateStatic();
const toHeading = useCallback(
(level: number) => {
return () => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.HeadingBlock,
data: {
level,
},
});
};
},
[editor]
);
const isActivated = useCallback(
(level: number) => {
const node = getBlock(editor) as HeadingNode;
if (!node) return false;
const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock);
return isBlock && node.data.level === level;
},
[editor]
);
return (
<div className={'flex items-center justify-center'}>
<ActionButton active={isActivated(1)} tooltip={t('editor.heading1')} onClick={toHeading(1)}>
<Heading1Svg />
</ActionButton>
<ActionButton active={isActivated(2)} tooltip={t('editor.heading2')} onClick={toHeading(2)}>
<Heading2Svg />
</ActionButton>
<ActionButton active={isActivated(3)} tooltip={t('editor.heading3')} onClick={toHeading(3)}>
<Heading3Svg />
</ActionButton>
</div>
);
}
export default Heading;

View File

@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
import { Editor } from 'slate';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function Href() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Href);
const onClick = useCallback(() => {
if (!editor.selection) return;
const text = Editor.string(editor, editor.selection);
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Href,
value: text,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.link')}>
<LinkSvg />
</ActionButton>
);
}
export default Href;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function InlineCode() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Code,
value: true,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.embedCode')}>
<CodeSvg />
</ActionButton>
);
}
export default InlineCode;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function Italic() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Italic,
value: true,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.italic')}>
<ItalicSvg />
</ActionButton>
);
}
export default Italic;

View File

@ -0,0 +1,27 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { ReactComponent as NumberedListSvg } from '$app/assets/numbers.svg';
export function NumberedList() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.NumberedListBlock);
const onClick = useCallback(() => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.NumberedListBlock,
});
}, [editor]);
return (
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.numberedList')}>
<NumberedListSvg />
</ActionButton>
);
}
export default NumberedList;

View File

@ -0,0 +1,33 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { getBlock } from '$app/components/editor/plugins/utils';
import { CustomEditor } from '$app/components/editor/command';
import { EditorNodeType } from '$app/application/document/document.types';
import { useSlateStatic } from 'slate-react';
import { ReactComponent as ParagraphSvg } from '$app/assets/text.svg';
export function Paragraph() {
const { t } = useTranslation();
const editor = useSlateStatic();
const onClick = useCallback(() => {
const node = getBlock(editor);
if (!node) return;
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.Paragraph,
});
}, [editor]);
const isActive = CustomEditor.isBlockActive(editor, EditorNodeType.Paragraph);
return (
<ActionButton active={isActive} onClick={onClick} tooltip={t('editor.text')}>
<ParagraphSvg />
</ActionButton>
);
}
export default Paragraph;

View File

@ -0,0 +1,27 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { EditorNodeType } from '$app/application/document/document.types';
import { CustomEditor } from '$app/components/editor/command';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { ReactComponent as QuoteSvg } from '$app/assets/quote.svg';
export function Quote() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.QuoteBlock);
const onClick = useCallback(() => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.QuoteBlock,
});
}, [editor]);
return (
<ActionButton active={isActivated} onClick={onClick} tooltip={t('editor.quote')}>
<QuoteSvg />
</ActionButton>
);
}
export default Quote;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function StrikeThrough() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.StrikeThrough,
value: true,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.strikethrough')}>
<StrikeThroughSvg />
</ActionButton>
);
}
export default StrikeThrough;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { EditorNodeType } from '$app/application/document/document.types';
import { ReactComponent as TodoListSvg } from '$app/assets/todo-list.svg';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
export function TodoList() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.TodoListBlock);
const onClick = useCallback(() => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.TodoListBlock,
});
}, [editor]);
return (
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.todoList')}>
<TodoListSvg />
</ActionButton>
);
}
export default TodoList;

View File

@ -0,0 +1,27 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { EditorNodeType } from '$app/application/document/document.types';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { ReactComponent as ToggleListSvg } from '$app/assets/show-menu.svg';
export function ToggleList() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.ToggleListBlock);
const onClick = useCallback(() => {
CustomEditor.turnToBlock(editor, {
type: EditorNodeType.ToggleListBlock,
});
}, [editor]);
return (
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.toggleList')}>
<ToggleListSvg />
</ActionButton>
);
}
export default ToggleList;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';
import { CustomEditor } from '$app/components/editor/command';
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
import { EditorMarkFormat } from '$app/application/document/document.types';
export function Underline() {
const { t } = useTranslation();
const editor = useSlateStatic();
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline);
const onClick = useCallback(() => {
CustomEditor.toggleMark(editor, {
key: EditorMarkFormat.Underline,
value: true,
});
}, [editor]);
return (
<ActionButton onClick={onClick} active={isActivated} tooltip={t('editor.underline')}>
<UnderlineSvg />
</ActionButton>
);
}
export default Underline;

View File

@ -5,11 +5,11 @@ import { CustomEditor } from '$app/components/editor/command';
export function getHeadingCssProperty(level: number) {
switch (level) {
case 1:
return 'text-3xl pt-4 pb-2';
return 'text-3xl py-[16px] font-bold';
case 2:
return 'text-2xl pt-3 pb-2';
return 'text-2xl py-[12px] font-bold';
case 3:
return 'text-xl pt-2 pb-2';
return 'text-xl py-[8px] font-bold';
default:
return '';
}

View File

@ -11,12 +11,12 @@ export function transformToInlineElement(op: Op): Element | null {
const attributes = op.attributes;
if (!attributes) return null;
const isFormula = attributes.formula;
const formula = attributes.formula as string;
if (isFormula) {
if (formula) {
return {
type: EditorInlineNodeType.Formula,
data: true,
data: formula,
children: [
{
text: op.insert as string,

View File

@ -1,11 +1,11 @@
import React, { useMemo } from 'react';
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import { IconButton } from '@mui/material';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useTranslation } from 'react-i18next';
import { DocumentSvg } from '$app/components/_shared/svg/DocumentSvg';
import { GridSvg } from '$app/components/_shared/svg/GridSvg';
import { BoardSvg } from '$app/components/_shared/svg/BoardSvg';
import { ReactComponent as DocumentSvg } from '$app/assets/document.svg';
import { ReactComponent as GridSvg } from '$app/assets/grid.svg';
import { ReactComponent as BoardSvg } from '$app/assets/board.svg';
import { ViewLayoutPB } from '@/services/backend';
function AddButton({ isVisible, onAddPage }: { isVisible: boolean; onAddPage: (layout: ViewLayoutPB) => void }) {
@ -67,7 +67,7 @@ function AddButton({ isVisible, onAddPage }: { isVisible: boolean; onAddPage: (l
popoverOptions={options}
isVisible={isVisible}
>
<IconButton className={'mr-2 h-6 w-6'}>
<IconButton size={'small'}>
<AddSvg />
</IconButton>
</ButtonPopoverList>

View File

@ -2,10 +2,12 @@ import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@mui/material';
import ButtonPopoverList from '../../_shared/ButtonPopoverList';
import { MoreHoriz } from '@mui/icons-material';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { CopySvg } from '$app/components/_shared/svg/CopySvg';
import { EditSvg } from '$app/components/_shared/svg/EditSvg';
import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
import { ReactComponent as EditSvg } from '$app/assets/edit.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import { ReactComponent as TrashSvg } from '$app/assets/delete.svg';
import RenameDialog from './RenameDialog';
import { Page } from '$app_reducers/pages/slice';
import DeleteDialog from '$app/components/layout/NestedPage/DeleteDialog';
@ -83,8 +85,8 @@ function MoreButton({
},
}}
>
<IconButton className={'h-6 w-6'}>
<MoreHoriz />
<IconButton size={'small'}>
<DetailsSvg />
</IconButton>
</ButtonPopoverList>
<RenameDialog

View File

@ -12,7 +12,7 @@ interface EditorInlineAttributes {
code?: boolean;
formula?: string;
prism_token?: string;
temporary?: string;
class_name?: string;
mention?: {
type: string;
// inline page ref id

View File

@ -14,6 +14,98 @@ export const getDesignTokens = (mode: ThemeMode): ThemeOptions => {
textTransform: 'none',
},
},
components: {
MuiIconButton: {
styleOverrides: {
root: {
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
borderRadius: '4px',
padding: '2px',
},
},
},
MuiButton: {
styleOverrides: {
contained: {
color: 'var(--content-on-fill)',
},
containedPrimary: {
'&:hover': {
backgroundColor: 'var(--fill-default)',
},
},
},
},
MuiButtonBase: {
defaultProps: {
sx: {
'&.Mui-selected:hover': {
backgroundColor: 'var(--fill-list-active)',
},
},
},
styleOverrides: {
root: {
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
'&:active': {
backgroundColor: 'var(--fill-list-active)',
},
borderRadius: '4px',
padding: '2px',
boxShadow: 'none',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
},
},
MuiDialog: {
defaultProps: {
sx: {
'& .MuiBackdrop-root': {
backgroundColor: 'var(--bg-mask)',
},
},
},
},
MuiTooltip: {
styleOverrides: {
arrow: {
color: 'var(--bg-tips)',
},
tooltip: {
backgroundColor: 'var(--bg-tips)',
color: 'var(--text-title)',
fontSize: '0.85rem',
borderRadius: '8px',
fontWeight: 400,
},
},
},
MuiInputBase: {
styleOverrides: {
input: {
backgroundColor: 'transparent !important',
},
},
},
MuiDivider: {
styleOverrides: {
root: {
borderColor: 'var(--line-divider)',
},
},
},
},
palette: {
mode: isDark ? 'dark' : 'light',
primary: {

View File

@ -1,140 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export function debounce(fn: (...args: any[]) => void, delay: number) {
let timeout: NodeJS.Timeout;
const debounceFn = (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
fn.apply(undefined, args);
}, delay);
};
debounceFn.cancel = () => {
clearTimeout(timeout);
};
return debounceFn;
}
export function throttle<T extends (...args: any[]) => void = (...args: any[]) => void>(
fn: T,
delay: number,
immediate = true
): T {
let timeout: NodeJS.Timeout | null = null;
const run = (...args: Parameters<T>) => {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
!immediate && fn.apply(undefined, args);
}, delay);
immediate && fn.apply(undefined, args);
}
};
return run as T;
}
export function get<T = any>(obj: any, path: string[], defaultValue?: any): T {
let value = obj;
for (const prop of path) {
if (value === undefined || typeof value !== 'object' || value[prop] === undefined) {
return defaultValue !== undefined ? defaultValue : undefined;
}
value = value[prop];
}
return value;
}
export function set(obj: any, path: string[], value: any): void {
let current = obj;
for (let i = 0; i < path.length; i++) {
const prop = path[i];
if (i === path.length - 1) {
current[prop] = value;
} else {
if (!current[prop]) {
current[prop] = {};
}
current = current[prop];
}
}
}
export function isEqual<T>(value1: T, value2: T): boolean {
if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) {
return value1 === value2;
}
if (Array.isArray(value1)) {
if (!Array.isArray(value2) || value1.length !== value2.length) {
return false;
}
for (let i = 0; i < value1.length; i++) {
if (!isEqual(value1[i], value2[i])) {
return false;
}
}
return true;
}
const keys1 = Object.keys(value1);
const keys2 = Object.keys(value2);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (!isEqual(value1[key], value2[key])) {
return false;
}
}
return true;
}
export function clone<T>(value: T): T {
if (typeof value !== 'object' || value === null) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => clone(item)) as any;
}
const result: any = {};
for (const key in value) {
result[key] = clone(value[key]);
}
return result;
}
export function chunkArray<T>(array: T[], chunkSize: number) {
const chunks = [];
let i = 0;
while (i < array.length) {
chunks.push(array.slice(i, i + chunkSize));
i += chunkSize;
}
return chunks;
}
/**
* Creates an interval that repeatedly calls the given function with a specified delay.
*

View File

@ -1,72 +0,0 @@
.MuiDialog-root [class$="-MuiBackdrop-root-MuiDialog-backdrop"] {
background-color: var(--bg-mask);
}
[class$="-MuiPaper-root-MuiPopover-paper"], [class$="-MuiPaper-root-MuiDialog-paper"] {
background-image: none !important;
}
[class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root.Mui-selected {
background-color: var(--fill-list-active);
}
[class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root:hover, [class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root.Mui-selected:hover {
background-color: var(--fill-list-active);
}
.MuiList-root .Mui-focusVisible.MuiMenuItem-root:not(:hover, .Mui-selected) {
background-color: unset;
}
.MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
background-image: none;
}
.MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedPrimary {
color: var(--content-on-fill);
}
.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeMedium {
border-radius: 4px;
}
.MuiButtonBase-root.MuiIconButton-root[class$='-MuiButtonBase-root-MuiIconButton-root']:hover {
background: var(--fill-list-hover);
}
.MuiButtonBase-root.MuiIconButton-root {
border-radius: 4px;
padding: 2px;
box-shadow: none;
}
.MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {
background: var(--fill-default);
}
.MuiTooltip-tooltip {
background: var(--bg-tips) !important;
color: var(--text-title) !important;
font-size: 0.85rem !important;
border-radius: 8px !important;
font-weight: 400 !important;
}
.MuiTooltip-arrow {
color: var(--bg-tips) !important;
}
.MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
background: transparent;
}
.MuiList-root .MuiMenuItem-root {
border-radius: 8px;
margin-left: 0.5em;
margin-right: 0.5em;
padding: 0.5em 1em;
}
.MuiDivider-root.MuiDivider-fullWidth {
border-color: var(--line-divider);
}

View File

@ -1,5 +1,4 @@
@import './variables/index.css';
@import './mui.css';
/* stop body from scrolling */
html,
@ -40,10 +39,35 @@ body {
scrollbar-shadow-color: var(--bg-body);
}
.block-element {
@apply my-[1px];
}
.block-element .block-element {
@apply mb-0;
}
.block-element[data-block-type="page"] .text-element {
min-height: 52px !important;
line-height: 52px !important;
}
.block-element[data-block-type="paragraph"] .block-element {
margin-left: 24px;
}
.block-element.block-align-left .text-element {
justify-content: flex-start;
}
.block-element.block-align-right .text-element {
justify-content: flex-end;
}
.block-element.block-align-center .text-element {
justify-content: center;
}
.block-element[data-block-type="todo_list"] .checked > .text-element {
text-decoration: line-through;
color: var(--text-caption);