{t('editor.textColor')}
@@ -163,9 +132,9 @@ function Color() {
}}
>
-
-
{t('editor.backgroundColor')}
-
- {editorBgColors.map((color, index) => {
- return (
-
- handlePickedColor(EditorMarkFormat.BgColor, color.color)}
- >
-
-
-
-
- );
- })}
-
-
);
- }, [activeBgColor, activeFontColor, editorBgColors, editorTextColors, handlePickedColor, t]);
+ }, [activeFontColor, editorTextColors, handlePickedColor, t]);
return (
<>
-
-
+
+
+
+
{toolbarVisible && (
mark[EditorMarkFormat.BgColor])?.[EditorMarkFormat.BgColor];
+
+ const [anchorEl, setAnchorEl] = useState(null);
+ const open = Boolean(anchorEl);
+
+ const wrapperRef = useRef(null);
+
+ useEffect(() => {
+ if (wrapperRef.current) {
+ const svg = wrapperRef.current.querySelector('svg');
+
+ if (svg) {
+ const bar = svg.querySelector('[class*="color-bar"]');
+
+ if (bar) {
+ bar.setAttribute('stroke', activeBgColor ? renderColor(activeBgColor) : 'currentColor');
+ }
+ }
+ }
+ }, [activeBgColor]);
+
+ useEffect(() => {
+ if (!toolbarVisible) {
+ setAnchorEl(null);
+ }
+ }, [toolbarVisible]);
+
+ const onClick = useCallback((e: MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setAnchorEl(e.currentTarget);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setAnchorEl(null);
+ }, []);
+
+ const handlePickedColor = useCallback(
+ (format: EditorMarkFormat, color: string) => {
+ if (color) {
+ CustomEditor.addMark(editor, {
+ key: format,
+ value: color,
+ });
+ } else {
+ CustomEditor.removeMark(editor, format);
+ }
+ },
+ [editor]
+ );
+
+ const editorBgColors = useMemo(() => {
+ return [
+ {
+ label: t('editor.backgroundColorDefault'),
+ color: '',
+ },
+ {
+ label: t('editor.backgroundColorLime'),
+ color: ColorEnum.Lime,
+ },
+ {
+ label: t('editor.backgroundColorAqua'),
+ color: ColorEnum.Aqua,
+ },
+ {
+ label: t('editor.backgroundColorOrange'),
+ color: ColorEnum.Orange,
+ },
+ {
+ label: t('editor.backgroundColorYellow'),
+ color: ColorEnum.Yellow,
+ },
+ {
+ label: t('editor.backgroundColorGreen'),
+ color: ColorEnum.Green,
+ },
+ {
+ label: t('editor.backgroundColorBlue'),
+ color: ColorEnum.Blue,
+ },
+ {
+ label: t('editor.backgroundColorPurple'),
+ color: ColorEnum.Purple,
+ },
+ {
+ label: t('editor.backgroundColorPink'),
+ color: ColorEnum.Pink,
+ },
+ {
+ label: t('editor.backgroundColorRed'),
+ color: ColorEnum.LightPink,
+ },
+ ];
+ }, [t]);
+
+ const popoverContent = useMemo(() => {
+ return (
+
+
+
{t('editor.backgroundColor')}
+
+ {editorBgColors.map((color, index) => {
+ return (
+
+ handlePickedColor(EditorMarkFormat.BgColor, color.color)}
+ >
+ {activeBgColor === color.color ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+ }, [activeBgColor, editorBgColors, handlePickedColor, t]);
+
+ return (
+ <>
+
+
+
+
+
+ {toolbarVisible && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onMouseUp={(e) => {
+ e.stopPropagation();
+ }}
+ disableRestoreFocus={true}
+ disableAutoFocus={true}
+ disableEnforceFocus={true}
+ open={open}
+ onClose={handleClose}
+ anchorEl={anchorEl}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'center',
+ }}
+ transformOrigin={{
+ vertical: -8,
+ horizontal: 'center',
+ }}
+ >
+ {popoverContent}
+
+ )}
+ >
+ );
+}
+
+export default ColorHighlight;
\ No newline at end of file
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx
index 0b86ca0b5..fced87607 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx
@@ -1,19 +1,21 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Editor,Text, Transforms } from 'slate';
+import { useSlate } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as MathSvg } from '@/assets/icons/formula.svg';
import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton';
import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
-import React, { useCallback, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Transforms, Text, Editor } from 'slate';
-import { useSlate } from 'slate-react';
-import { ReactComponent as MathSvg } from '@/assets/icons/formula.svg';
+
function Formula() {
const { t } = useTranslation();
const editor = useSlate() as YjsEditor;
const { visible } = useSelectionToolbarContext();
- const [state, setState] = React.useState({
+ const [state, setState] = useState({
isActivated: false,
hasFormulaActivated: false,
hasMentionActivated: false,
@@ -99,7 +101,7 @@ function Formula() {
disabled={!isActivated && (hasFormulaActivated || hasMentionActivated)}
tooltip={t('document.plugins.createInlineMathEquation')}
>
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Heading.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Heading.tsx
index 94191be1b..5d82327c3 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Heading.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Heading.tsx
@@ -1,39 +1,69 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { BlockType, HeadingBlockData } from '@/application/types';
-import { Popover } from '@/components/_shared/popover';
-import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
-import { PopoverProps } from '@mui/material/Popover';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import ActionButton from './ActionButton';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
import { ReactComponent as Heading1 } from '@/assets/icons/h1.svg';
import { ReactComponent as Heading2 } from '@/assets/icons/h2.svg';
import { ReactComponent as Heading3 } from '@/assets/icons/h3.svg';
+import { ReactComponent as ParagraphSvg } from '@/assets/icons/text.svg';
+import { ReactComponent as TextFormatSvg } from '@/assets/icons/text_format.svg';
import { ReactComponent as DownArrow } from '@/assets/icons/triangle_down.svg';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
+
+import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
-const popoverProps: Partial
= {
- anchorOrigin: {
- vertical: 'bottom',
- horizontal: 'center',
+import ActionButton from './ActionButton';
+import { MenuButton } from './MenuButton';
+
+// Define allowed translation keys for heading options
+const headingLabelKeys = [
+ 'editor.text',
+ 'document.slashMenu.name.heading1',
+ 'document.slashMenu.name.heading2',
+ 'document.slashMenu.name.heading3',
+] as const;
+
+type HeadingLabelKey = typeof headingLabelKeys[number];
+
+const headingOptions = [
+ {
+ icon: ,
+ labelKey: 'editor.text' as HeadingLabelKey,
+ isActive: (isParagraph: () => boolean, _isActivated: (level: number) => boolean) => isParagraph(),
+ onClick: (toParagraph: () => void, _toHeading: (level: number) => () => void, setOpen: (v: boolean) => void) => () => { toParagraph(); setOpen(false); },
},
- transformOrigin: {
- vertical: -8,
- horizontal: 'center',
+ {
+ icon: ,
+ labelKey: 'document.slashMenu.name.heading1' as HeadingLabelKey,
+ isActive: (_isParagraph: () => boolean, isActivated: (level: number) => boolean) => isActivated(1),
+ onClick: (_toParagraph: () => void, toHeading: (level: number) => () => void, setOpen: (v: boolean) => void) => () => { toHeading(1)(); setOpen(false); },
},
- slotProps: {
- paper: {
- className: 'bg-[var(--fill-toolbar)] rounded-[6px]',
- },
+ {
+ icon: ,
+ labelKey: 'document.slashMenu.name.heading2' as HeadingLabelKey,
+ isActive: (_isParagraph: () => boolean, isActivated: (level: number) => boolean) => isActivated(2),
+ onClick: (_toParagraph: () => void, toHeading: (level: number) => () => void, setOpen: (v: boolean) => void) => () => { toHeading(2)(); setOpen(false); },
},
-};
+ {
+ icon: ,
+ labelKey: 'document.slashMenu.name.heading3' as HeadingLabelKey,
+ isActive: (_isParagraph: () => boolean, isActivated: (level: number) => boolean) => isActivated(3),
+ onClick: (_toParagraph: () => void, toHeading: (level: number) => () => void, setOpen: (v: boolean) => void) => () => { toHeading(3)(); setOpen(false); },
+ },
+];
export function Heading() {
const { t } = useTranslation();
const editor = useSlateStatic() as YjsEditor;
- const { visible: toolbarVisible } = useSelectionToolbarContext();
+ const { visible: toolbarVisible, forceShow } = useSelectionToolbarContext();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
const toHeading = useCallback(
(level: number) => {
return () => {
@@ -71,24 +101,41 @@ export function Heading() {
[editor]
);
- const getActiveButton = useCallback(() => {
- if (isActivated(1)) {
- return ;
- }
-
- if (isActivated(2)) {
- return ;
- }
+ const isParagraph = useCallback(() => {
+ try {
+ const [node] = getBlockEntry(editor);
- if (isActivated(3)) {
- return ;
+ return node && node.type === BlockType.Paragraph;
+ } catch (e) {
+ return false;
}
+ }, [editor]);
- return ;
- }, [isActivated]);
+ const toParagraph = useCallback(() => {
+ try {
+ const [node] = getBlockEntry(editor);
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
+ if (!node) return;
+ CustomEditor.turnToBlock(editor, node.blockId as string, BlockType.Paragraph, {});
+ } catch (e) {
+ return;
+ }
+ }, [editor]);
+
+ const { getButtonProps, selectedIndex } = useKeyboardNavigation({
+ itemCount: 4,
+ isOpen: open,
+ onSelect: (index) => {
+ if (index === 0) {
+ toParagraph();
+ } else {
+ toHeading(index)();
+ }
+ },
+ onClose: () => {
+ setOpen(false);
+ }
+ });
useEffect(() => {
if (!toolbarVisible) {
@@ -98,47 +145,47 @@ export function Heading() {
return (
-
{
- e.preventDefault();
- e.stopPropagation();
- setOpen(true);
- }}
- tooltip={'Heading'}
- >
-
- {getActiveButton()}
-
-
-
- {toolbarVisible && (
-
{
- setOpen(false);
- }}
- open={open}
- anchorEl={ref.current}
- {...popoverProps}
- >
-
-
-
-
-
-
-
-
-
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setOpen(true);
+ forceShow(true);
+ }}
+ tooltip={t('editor.text')}
+ >
+
+
+
+
-
- )}
+
+ {toolbarVisible && (
+
+
+ {headingOptions.map((opt, idx) => (
+ opt.onClick(toParagraph, toHeading, setOpen)}
+ selected={selectedIndex === idx}
+ buttonProps={getButtonProps(idx)}
+ />
+ ))}
+
+
+ )}
+
);
}
export default Heading;
+
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Href.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Href.tsx
index 03c22c38b..cd925a5a9 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Href.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Href.tsx
@@ -1,14 +1,18 @@
+import { useCallback, useEffect, useMemo, useState, MouseEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ReactEditor, useSlate } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as LinkSvg } from '@/assets/icons/link.svg';
+import HrefPopover from '@/components/editor/components/leaf/href/HrefPopover';
import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
import { createHotkey, getModifier, HOT_KEY_NAME } from '@/utils/hotkeys';
-import React, { useCallback, useEffect, useMemo } from 'react';
+
import ActionButton from './ActionButton';
-import { useTranslation } from 'react-i18next';
-import { ReactEditor, useSlate } from 'slate-react';
-import { ReactComponent as LinkSvg } from '@/assets/icons/link.svg';
-import HrefPopover from '@/components/editor/components/leaf/href/HrefPopover';
+
+
export function Href() {
const { t } = useTranslation();
@@ -17,13 +21,13 @@ export function Href() {
const editor = useSlate() as YjsEditor;
const { visible } = useSelectionToolbarContext();
- const [state, setState] = React.useState({
+ const [state, setState] = useState({
isActivated: false,
hasFormulaActivated: false,
hasMentionActivated: false,
});
- const [open, setOpen] = React.useState(false);
+ const [open, setOpen] = useState(false);
const { isActivated, hasFormulaActivated, hasMentionActivated } = state;
const getState = useCallback(() => {
@@ -44,7 +48,7 @@ export function Href() {
}, [visible, getState, editor.selection]);
const onClick = useCallback(
- (e: React.MouseEvent) => {
+ (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setOpen(true);
@@ -93,7 +97,7 @@ export function Href() {
return (
<>
-
+
}
>
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Italic.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Italic.tsx
index 6e7312c3f..532401a7b 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Italic.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Italic.tsx
@@ -1,11 +1,13 @@
+import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as ItalicSvg } from '@/assets/icons/italic.svg';
import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton';
import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
-import React, { useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
-import { ReactComponent as ItalicSvg } from '@/assets/icons/italic.svg';
+
export function Italic() {
const { t } = useTranslation();
@@ -31,7 +33,7 @@ export function Italic() {
>
}
>
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/MenuButton.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/MenuButton.tsx
new file mode 100644
index 000000000..bd187ef9e
--- /dev/null
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/MenuButton.tsx
@@ -0,0 +1,40 @@
+import { ReactNode } from 'react';
+import { Button, buttonVariants } from '@/components/ui/button';
+import { ReactComponent as TickIcon } from '@/assets/icons/tick.svg';
+import { VariantProps } from 'class-variance-authority';
+
+interface MenuButtonProps {
+ icon: ReactNode;
+ label: ReactNode;
+ isActive?: boolean;
+ onClick: () => void;
+ selected?: boolean;
+ buttonProps?: React.ComponentProps<'button'> & VariantProps & { asChild?: boolean };
+}
+
+export function MenuButton({ icon, label, isActive, onClick, selected, buttonProps }: MenuButtonProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/MoreOptions.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/MoreOptions.tsx
new file mode 100644
index 000000000..823dda8b8
--- /dev/null
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/MoreOptions.tsx
@@ -0,0 +1,131 @@
+import { useTranslation } from 'react-i18next';
+import { Editor, Text, Transforms } from 'slate';
+import { useSlate } from 'slate-react';
+
+import { CustomEditor } from '@/application/slate-yjs/command';
+import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as FormulaSvg } from '@/assets/icons/formula.svg';
+import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg';
+import { ReactComponent as StrikeThroughSvg } from '@/assets/icons/strikethrough.svg';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
+
+import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
+import ActionButton from './ActionButton';
+import { useRef, useState } from 'react';
+import { MenuButton } from './MenuButton';
+
+const options = [
+ {
+ icon: ,
+ labelKey: 'editor.strikethrough' as const,
+ isActive: (editor: Editor) => CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough),
+ onClick: (editor: Editor, setOpen: (v: boolean) => void) => {
+ CustomEditor.toggleMark(editor, {
+ key: EditorMarkFormat.StrikeThrough,
+ value: true,
+ });
+ setOpen(false);
+ },
+ },
+ {
+ icon: ,
+ labelKey: 'document.plugins.createInlineMathEquation' as const,
+ isActive: (editor: Editor) => CustomEditor.isMarkActive(editor, EditorMarkFormat.Formula),
+ onClick: (editor: Editor, setOpen: (v: boolean) => void) => {
+ const selection = editor.selection;
+
+ if (!selection) return;
+ const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Formula);
+
+ if (!isActivated) {
+ const text = editor.string(selection);
+
+ editor.delete();
+ editor.insertText('$');
+ const newSelection = editor.selection;
+
+ if (!newSelection) return;
+ Transforms.select(editor, {
+ anchor: {
+ path: newSelection.anchor.path,
+ offset: newSelection.anchor.offset - 1,
+ },
+ focus: newSelection.focus,
+ });
+ CustomEditor.addMark(editor, {
+ key: EditorMarkFormat.Formula,
+ value: text,
+ });
+ } else {
+ const [entry] = editor.nodes({
+ at: selection,
+ match: (n) => !Editor.isEditor(n) && Text.isText(n) && n.formula !== undefined,
+ });
+
+ if (!entry) return;
+ const [node, path] = entry;
+ const { formula } = node as Text;
+
+ if (!formula) return;
+ editor.select(path);
+ CustomEditor.removeMark(editor, EditorMarkFormat.Formula);
+ editor.delete();
+ editor.insertText(formula);
+ }
+
+ setOpen(false);
+ },
+ },
+];
+
+export default function MoreOptions() {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const { t } = useTranslation();
+ const editor = useSlate();
+ const { forceShow } = useSelectionToolbarContext();
+
+ const { getButtonProps, selectedIndex } = useKeyboardNavigation({
+ itemCount: options.length,
+ isOpen: open,
+ onSelect: (index) => options[index].onClick(editor, setOpen),
+ onClose: () => setOpen(false),
+ });
+
+ return (
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setOpen(true);
+ forceShow(true);
+ }}
+ tooltip={t('toolbar.moreOptions', { defaultValue: 'More options' })}
+ >
+
+
+
+
+
+
+ {options.map((opt, idx) => (
+ opt.onClick(editor, setOpen)}
+ selected={selectedIndex === idx}
+ buttonProps={getButtonProps(idx)}
+ />
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/NumberedList.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/NumberedList.tsx
index 17268fdcf..2d1258f22 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/NumberedList.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/NumberedList.tsx
@@ -1,13 +1,15 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { BlockType } from '@/application/types';
-import React, { useCallback } from 'react';
-import ActionButton from './ActionButton';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
import { ReactComponent as NumberedListSvg } from '@/assets/icons/numbered_list.svg';
+import ActionButton from './ActionButton';
+
export function NumberedList() {
const { t } = useTranslation();
const editor = useSlateStatic() as YjsEditor;
@@ -32,7 +34,7 @@ export function NumberedList() {
return (
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Paragraph.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Paragraph.tsx
index 32eeb26da..2d8e90205 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Paragraph.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Paragraph.tsx
@@ -1,11 +1,13 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { BlockType } from '@/application/types';
import { ReactComponent as ParagraphSvg } from '@/assets/icons/text.svg';
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
+
import ActionButton from './ActionButton';
export function Paragraph() {
@@ -24,7 +26,7 @@ export function Paragraph() {
return (
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Quote.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Quote.tsx
index e44983125..c07e8d24d 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Quote.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Quote.tsx
@@ -1,13 +1,15 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
import { BlockType } from '@/application/types';
-import React, { useCallback } from 'react';
-import ActionButton from './ActionButton';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
import { ReactComponent as QuoteSvg } from '@/assets/icons/quote.svg';
+import ActionButton from './ActionButton';
+
export function Quote() {
const { t } = useTranslation();
const editor = useSlateStatic() as YjsEditor;
@@ -32,7 +34,7 @@ export function Quote() {
return (
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/StrikeThrough.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/StrikeThrough.tsx
index 9dcb9ffc0..3eea2ecab 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/StrikeThrough.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/StrikeThrough.tsx
@@ -1,11 +1,13 @@
+import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as StrikeThroughSvg } from '@/assets/icons/strikethrough.svg';
import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton';
import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
-import React, { useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
-import { ReactComponent as StrikeThroughSvg } from '@/assets/icons/strikethrough.svg';
+
export function StrikeThrough() {
const { t } = useTranslation();
@@ -31,7 +33,7 @@ export function StrikeThrough() {
>
}
>
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/TurnInto.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/TurnInto.tsx
new file mode 100644
index 000000000..fdeeb80ac
--- /dev/null
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/TurnInto.tsx
@@ -0,0 +1,325 @@
+import { FC, SVGProps, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
+import { YjsEditor } from '@/application/slate-yjs';
+import { CustomEditor } from '@/application/slate-yjs/command';
+import { getBlockEntry } from '@/application/slate-yjs/utils/editor';
+import { type HeadingBlockData, BlockType, BlockData } from '@/application/types';
+import { ReactComponent as BulletedListSvg } from '@/assets/icons/bulleted_list.svg';
+import { ReactComponent as Heading1 } from '@/assets/icons/h1.svg';
+import { ReactComponent as Heading2 } from '@/assets/icons/h2.svg';
+import { ReactComponent as Heading3 } from '@/assets/icons/h3.svg';
+import { ReactComponent as NumberedListSvg } from '@/assets/icons/numbered_list.svg';
+import { ReactComponent as QuoteSvg } from '@/assets/icons/quote.svg';
+import { ReactComponent as ParagraphSvg } from '@/assets/icons/text.svg';
+import { ReactComponent as ToggleHeading1Icon } from '@/assets/icons/toggle_h1.svg';
+import { ReactComponent as ToggleHeading2Icon } from '@/assets/icons/toggle_h2.svg';
+import { ReactComponent as ToggleHeading3Icon } from '@/assets/icons/toggle_h3.svg';
+import { ReactComponent as ToggleListIcon } from '@/assets/icons/toggle_list.svg';
+import { ReactComponent as DownArrow } from '@/assets/icons/triangle_down.svg';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks';
+
+import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
+import ActionButton from './ActionButton';
+import { MenuButton } from './MenuButton';
+
+type BlockOption = {
+ type: 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'quote' | 'bulleted' | 'numbered' | 'toggleHeading1' | 'toggleHeading2' | 'toggleHeading3' | 'toggle';
+ icon: FC>;
+ label: string;
+ blockType: BlockType;
+ data?: BlockData | HeadingBlockData;
+ group: 'text' | 'list' | 'toggle' | 'other';
+};
+
+const blockOptions: BlockOption[] = [
+ {
+ type: 'paragraph',
+ icon: ParagraphSvg,
+ label: 'editor.text',
+ blockType: BlockType.Paragraph,
+ group: 'text',
+ },
+ {
+ type: 'heading1',
+ icon: Heading1,
+ label: 'toolbar.h1',
+ blockType: BlockType.HeadingBlock,
+ data: { level: 1 },
+ group: 'text',
+ },
+ {
+ type: 'heading2',
+ icon: Heading2,
+ label: 'toolbar.h2',
+ blockType: BlockType.HeadingBlock,
+ data: { level: 2 },
+ group: 'text',
+ },
+ {
+ type: 'heading3',
+ icon: Heading3,
+ label: 'toolbar.h3',
+ blockType: BlockType.HeadingBlock,
+ data: { level: 3 },
+ group: 'text',
+ },
+ {
+ type: 'bulleted',
+ icon: BulletedListSvg,
+ label: 'editor.bulletedListShortForm',
+ blockType: BlockType.BulletedListBlock,
+ group: 'list',
+ },
+ {
+ type: 'numbered',
+ icon: NumberedListSvg,
+ label: 'editor.numberedListShortForm',
+ blockType: BlockType.NumberedListBlock,
+ group: 'list',
+ },
+ {
+ type: 'toggle',
+ icon: ToggleListIcon,
+ label: 'editor.toggleListShortForm',
+ blockType: BlockType.ToggleListBlock,
+ group: 'toggle',
+ },
+ {
+ type: 'toggleHeading1',
+ icon: ToggleHeading1Icon,
+ label: 'editor.toggleHeading1ShortForm',
+ blockType: BlockType.ToggleListBlock,
+ data: { level: 1 },
+ group: 'toggle',
+ },
+ {
+ type: 'toggleHeading2',
+ icon: ToggleHeading2Icon,
+ label: 'editor.toggleHeading2ShortForm',
+ blockType: BlockType.ToggleListBlock,
+ data: { level: 2 },
+ group: 'toggle',
+ },
+ {
+ type: 'toggleHeading3',
+ icon: ToggleHeading3Icon,
+ label: 'editor.toggleHeading3ShortForm',
+ blockType: BlockType.ToggleListBlock,
+ data: { level: 3 },
+ group: 'toggle',
+ },
+ {
+ type: 'quote',
+ icon: QuoteSvg,
+ label: 'editor.quote',
+ blockType: BlockType.QuoteBlock,
+ group: 'other',
+ },
+];
+
+function isHeadingBlockData(data: BlockOption['data']): data is HeadingBlockData {
+ return !!data && typeof (data as HeadingBlockData).level === 'number';
+}
+
+function TurnInfo() {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const editor = useSlateStatic() as YjsEditor;
+ const { t } = useTranslation();
+ const { visible, forceShow } = useSelectionToolbarContext();
+
+ // Helper: get current block type and heading level
+ let currentType: string | null = null;
+ let currentLevel: number | null = null;
+ let currentGroup: BlockOption['group'] | null = null;
+
+ try {
+ const [node] = getBlockEntry(editor);
+
+ switch (node.type) {
+ case BlockType.Paragraph:
+ currentType = 'paragraph';
+ currentGroup = 'text';
+ break;
+ case BlockType.HeadingBlock:
+ currentType = 'heading';
+ currentLevel = (node.data as HeadingBlockData).level;
+ currentGroup = 'text';
+ break;
+ case BlockType.QuoteBlock:
+ currentType = 'quote';
+ currentGroup = 'other';
+ break;
+ case BlockType.BulletedListBlock:
+ currentType = 'bulleted';
+ currentGroup = 'list';
+ break;
+ case BlockType.NumberedListBlock:
+ currentType = 'numbered';
+ currentGroup = 'list';
+ break;
+ case BlockType.ToggleListBlock:
+ currentType = 'toggle';
+ currentGroup = 'toggle';
+ break;
+ }
+ } catch (e) { }
+
+ const getDisplayText = () => {
+ if (currentType === 'paragraph') return t('editor.text', { defaultValue: 'Text' });
+ if (currentType === 'heading' && currentLevel) {
+ return t(`document.slashMenu.name.heading${currentLevel}`, { defaultValue: `Heading ${currentLevel}` });
+ }
+
+ if (currentType === 'quote') return t('toolbar.quote', { returnObjects: false, defaultValue: 'Quote' });
+ if (currentType === 'bulleted') return t('toolbar.bulletList', { returnObjects: false, defaultValue: 'Bulleted List' });
+ if (currentType === 'numbered') return t('toolbar.numberedList', { returnObjects: false, defaultValue: 'Numbered List' });
+
+ return 'Text';
+ };
+
+ const handleBlockChange = (option: BlockOption) => {
+ try {
+ const [node] = getBlockEntry(editor);
+
+ if (!node) return;
+
+ if (node.type === option.blockType &&
+ (!option.data || (node.type === BlockType.HeadingBlock && isHeadingBlockData(option.data) && (node.data as HeadingBlockData).level === option.data.level))) {
+ CustomEditor.turnToBlock(editor, node.blockId as string, BlockType.Paragraph, {});
+ } else {
+ CustomEditor.turnToBlock(editor, node.blockId as string, option.blockType, option.data || {});
+ }
+
+ setOpen(false);
+ } catch (e) { setOpen(false); }
+ };
+
+ const getSuggestionOptions = () => {
+ if (!currentGroup) return [];
+
+ // Get all options from the same group except the current one
+ return blockOptions.filter(option =>
+ option.group === currentGroup &&
+ (option.type !== currentType &&
+ !(currentType === 'heading' && option.type === `heading${currentLevel}`))
+ );
+ };
+
+ const getTurnIntoOptions = () => {
+ const suggestionOptions = getSuggestionOptions();
+
+ // Filter out options that are already in suggestions
+ return blockOptions.filter(option =>
+ !suggestionOptions.some(suggestion => suggestion.type === option.type)
+ );
+ };
+
+ const suggestionOptions = getSuggestionOptions();
+ const turnIntoOptions = getTurnIntoOptions();
+
+ const { getButtonProps, selectedIndex } = useKeyboardNavigation({
+ itemCount: suggestionOptions.length + turnIntoOptions.length,
+ isOpen: open,
+ onSelect: (index) => {
+ const options = [...suggestionOptions, ...turnIntoOptions];
+
+ handleBlockChange(options[index]);
+ },
+ onClose: () => setOpen(false)
+ });
+
+ function isOptionActive(option: BlockOption, currentType: string | null, currentLevel: number | null) {
+ if (!currentType) return false;
+ if (option.type === 'paragraph' && currentType === 'paragraph') return true;
+ if (option.type.startsWith('heading') && currentType === 'heading') {
+ return isHeadingBlockData(option.data) && option.data.level === currentLevel;
+ }
+
+ if (option.type === 'bulleted' && currentType === 'bulleted') return true;
+ if (option.type === 'numbered' && currentType === 'numbered') return true;
+ if (option.type === 'toggle' && currentType === 'toggle') return true;
+ if (option.type.startsWith('toggleHeading') && currentType === 'toggle' && isHeadingBlockData(option.data) && option.data.level === currentLevel) return true;
+ if (option.type === 'quote' && currentType === 'quote') return true;
+ return false;
+ }
+
+ return (
+
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ setOpen(true);
+ forceShow(true);
+ }}
+ tooltip={getDisplayText()}
+ >
+
+
+ {getDisplayText()}
+
+
+
+
+
+
+ {visible && (
+
+
+ {suggestionOptions.length > 0 && (
+ <>
+
+ {t('toolbar.suggestions', { defaultValue: 'Suggestions' })}
+
+ {suggestionOptions.map((option, idx) => (
+
}
+ label={t(option.label, { defaultValue: option.label })}
+ isActive={isOptionActive(option, currentType, currentLevel)}
+ onClick={() => handleBlockChange(option)}
+ selected={selectedIndex === idx}
+ buttonProps={getButtonProps(idx)}
+ />
+ ))}
+ >
+ )}
+
+ {t('toolbar.turnInto', { defaultValue: 'Turn into' })}
+
+ {turnIntoOptions.map((option, idx) => (
+
}
+ label={t(option.label, { defaultValue: option.label })}
+ isActive={isOptionActive(option, currentType, currentLevel)}
+ onClick={() => handleBlockChange(option)}
+ selected={selectedIndex === idx + suggestionOptions.length}
+ buttonProps={getButtonProps(idx + suggestionOptions.length)}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+export default TurnInfo;
\ No newline at end of file
diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/Underline.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/Underline.tsx
index e084940b9..ebdb2c83e 100644
--- a/src/components/editor/components/toolbar/selection-toolbar/actions/Underline.tsx
+++ b/src/components/editor/components/toolbar/selection-toolbar/actions/Underline.tsx
@@ -1,11 +1,13 @@
+import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSlateStatic } from 'slate-react';
+
import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
+import { ReactComponent as UnderlineSvg } from '@/assets/icons/underline.svg';
import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton';
import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
-import React, { useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSlateStatic } from 'slate-react';
-import { ReactComponent as UnderlineSvg } from '@/assets/icons/underline.svg';
+
export function Underline() {
const { t } = useTranslation();
@@ -31,7 +33,7 @@ export function Underline() {
>
}
>
-
+
);
}
diff --git a/src/components/editor/components/toolbar/selection-toolbar/hooks/useKeyboardNavigation.ts b/src/components/editor/components/toolbar/selection-toolbar/hooks/useKeyboardNavigation.ts
new file mode 100644
index 000000000..761efedcc
--- /dev/null
+++ b/src/components/editor/components/toolbar/selection-toolbar/hooks/useKeyboardNavigation.ts
@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
+
+interface UseKeyboardNavigationProps {
+ itemCount: number;
+ onSelect: (index: number) => void;
+ onClose: () => void;
+ isOpen: boolean;
+}
+
+export function useKeyboardNavigation({ itemCount, onSelect, onClose, isOpen }: UseKeyboardNavigationProps) {
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setSelectedIndex(-1);
+ return;
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (createHotkey(HOT_KEY_NAME.UP)(e) || createHotkey(HOT_KEY_NAME.DOWN)(e)) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (createHotkey(HOT_KEY_NAME.UP)(e)) {
+ setSelectedIndex(prev => (prev <= 0 ? itemCount - 1 : prev - 1));
+ } else {
+ setSelectedIndex(prev => (prev >= itemCount - 1 ? 0 : prev + 1));
+ }
+ } else if (createHotkey(HOT_KEY_NAME.ENTER)(e)) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (selectedIndex >= 0) {
+ onSelect(selectedIndex);
+ onClose();
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown, true);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown, true);
+ };
+ }, [isOpen, selectedIndex, itemCount, onSelect, onClose]);
+
+ const getButtonProps = useCallback((index: number) => ({
+ ref: (el: HTMLButtonElement | null) => {
+ buttonRefs.current[index] = el;
+ },
+ sx: {
+ '&:hover': {
+ backgroundColor: 'var(--fill-list-hover)'
+ },
+ ...(selectedIndex === index && {
+ backgroundColor: 'var(--fill-list-hover)'
+ })
+ }
+ }), [selectedIndex]);
+
+ return {
+ selectedIndex,
+ getButtonProps
+ };
+}
\ No newline at end of file
diff --git a/src/components/main/AppTheme.tsx b/src/components/main/AppTheme.tsx
index 55306a35c..c5add2830 100644
--- a/src/components/main/AppTheme.tsx
+++ b/src/components/main/AppTheme.tsx
@@ -1,12 +1,12 @@
-import { ThemeModeContext, useAppThemeMode } from '@/components/main/useAppThemeMode';
-import React, { useMemo } from 'react';
import createTheme from '@mui/material/styles/createTheme';
import ThemeProvider from '@mui/material/styles/ThemeProvider';
-import { i18nInstance } from '@/i18n/config';
-
+import { ReactNode, useMemo } from 'react';
import { I18nextProvider } from 'react-i18next';
-function AppTheme({ children }: { children: React.ReactNode }) {
+import { ThemeModeContext, useAppThemeMode } from '@/components/main/useAppThemeMode';
+import { i18nInstance } from '@/i18n/config';
+
+function AppTheme({ children }: { children: ReactNode }) {
const { isDark, setIsDark } = useAppThemeMode();
const theme = useMemo(
@@ -191,7 +191,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
MuiTooltip: {
styleOverrides: {
arrow: {
- color: 'var(--fill-toolbar)',
+ color: 'var(--surface-primary)',
},
tooltip: {
backgroundColor: 'var(--fill-toolbar)',
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index 30e92a2ab..210827304 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -3,15 +3,15 @@ import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
-function Popover ({ ...props }: React.ComponentProps) {
+function Popover({ ...props }: React.ComponentProps) {
return ;
}
-function PopoverTrigger ({ ...props }: React.ComponentProps) {
+function PopoverTrigger({ ...props }: React.ComponentProps) {
return ;
}
-function PopoverContent ({
+function PopoverContent({
className,
align = 'center',
sideOffset = 4,
@@ -33,7 +33,7 @@ function PopoverContent ({
);
}
-function PopoverAnchor ({ ...props }: React.ComponentProps) {
+function PopoverAnchor({ ...props }: React.ComponentProps) {
return ;
}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
index ffbc05360..ace291958 100644
--- a/src/components/ui/separator.tsx
+++ b/src/components/ui/separator.tsx
@@ -3,7 +3,7 @@ import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
-function Separator ({
+function Separator({
className,
orientation = 'horizontal',
decorative = true,
@@ -15,7 +15,7 @@ function Separator ({
decorative={decorative}
orientation={orientation}
className={cn(
- 'bg-border-primary shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
+ 'shrink-0 bg-border-primary data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index f193c37ec..a6d3ec81d 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -31,6 +31,7 @@ module.exports = {
600: '20px',
xs: '4px',
sm: '6px',
+ m: '8px',
md: '12px',
lg: '16px',
xl: '20px',
diff --git a/vite.config.ts b/vite.config.ts
index bb36c046b..248c32c46 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -99,6 +99,7 @@ export default defineConfig({
server: {
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
strictPort: true,
+ host: '0.0.0.0',
watch: {
ignored: ['node_modules'],
},
@@ -125,7 +126,7 @@ export default defineConfig({
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
manualChunks(id) {
- if(
+ if (
// id.includes('/react@') ||
// id.includes('/react-dom@') ||
id.includes('/react-is@') ||