feat: support editor format text color and bg color (#3061)

This commit is contained in:
Kilu.He 2023-07-27 20:40:18 +08:00 committed by GitHub
parent 915ce02157
commit a885170869
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 750 additions and 106 deletions

View File

@ -46,6 +46,7 @@
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-calendar": "^4.1.0",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^12.2.0",
@ -72,6 +73,7 @@
"@types/quill": "^2.0.10",
"@types/react": "^18.0.15",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-color": "^3.0.6",
"@types/react-dom": "^18.0.6",
"@types/react-katex": "^3.0.0",
"@types/react-transition-group": "^4.4.6",

View File

@ -88,6 +88,9 @@ dependencies:
react-calendar:
specifier: ^4.1.0
version: 4.2.1(react-dom@18.2.0)(react@18.2.0)
react-color:
specifier: ^2.19.3
version: 2.19.3(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
@ -162,6 +165,9 @@ devDependencies:
'@types/react-beautiful-dnd':
specifier: ^13.1.3
version: 13.1.4
'@types/react-color':
specifier: ^3.0.6
version: 3.0.6
'@types/react-dom':
specifier: ^18.0.6
version: 18.2.4
@ -960,6 +966,14 @@ packages:
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
dev: true
/@icons/material@0.2.4(react@18.2.0):
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
peerDependencies:
react: '*'
dependencies:
react: 18.2.0
dev: false
/@istanbuljs/load-nyc-config@1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'}
@ -1701,6 +1715,13 @@ packages:
'@types/react': 18.2.6
dev: true
/@types/react-color@3.0.6:
resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==}
dependencies:
'@types/react': 18.2.6
'@types/reactcss': 1.2.6
dev: true
/@types/react-dom@18.2.4:
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
dependencies:
@ -1747,6 +1768,12 @@ packages:
'@types/scheduler': 0.16.3
csstype: 3.1.2
/@types/reactcss@1.2.6:
resolution: {integrity: sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==}
dependencies:
'@types/react': 18.2.6
dev: true
/@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
@ -3975,6 +4002,10 @@ packages:
p-locate: 5.0.0
dev: true
/lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: false
/lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
@ -4035,6 +4066,10 @@ packages:
tmpl: 1.0.5
dev: false
/material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
dev: false
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
@ -4593,6 +4628,21 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-color@2.19.3(react@18.2.0):
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
peerDependencies:
react: '*'
dependencies:
'@icons/material': 0.2.4(react@18.2.0)
lodash: 4.17.21
lodash-es: 4.17.21
material-colors: 1.2.6
prop-types: 15.8.1
react: 18.2.0
reactcss: 1.2.3(react@18.2.0)
tinycolor2: 1.6.0
dev: false
/react-dom@18.2.0(react@18.2.0):
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@ -4770,6 +4820,15 @@ packages:
loose-envify: 1.4.0
dev: false
/reactcss@1.2.3(react@18.2.0):
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
peerDependencies:
react: '*'
dependencies:
lodash: 4.17.21
react: 18.2.0
dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies:
@ -5230,7 +5289,6 @@ packages:
/tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
dev: true
/tmpl@1.0.5:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}

View File

@ -40,9 +40,6 @@ function BlockDraggable(
data-draggable-type={type}
onMouseDown={getAnchorEl ? undefined : onDragStart}
className={`relative ${className || ''}`}
style={{
opacity: isDragging ? 0.7 : 1,
}}
{...props}
>
{

View File

@ -143,7 +143,7 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
return (
<BlockMenuTurnInto
key={option.key}
lable={option.title}
label={option.title}
onHovered={() => {
setHovered(BlockMenuOption.TurnInto);
setSubMenuOpened(true);

View File

@ -9,14 +9,14 @@ function BlockMenuTurnInto({
onHovered,
isHovered,
menuOpened,
lable,
label,
}: {
id: string;
onClose: () => void;
onHovered: (e: MouseEvent) => void;
isHovered: boolean;
menuOpened: boolean;
lable?: string;
label?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
@ -39,7 +39,7 @@ function BlockMenuTurnInto({
<>
<MenuItem
ref={ref}
title={lable}
title={label}
isHovered={isHovered}
icon={<Transform />}
extra={<ArrowRight />}
@ -60,7 +60,10 @@ function BlockMenuTurnInto({
pointerEvents: 'auto',
},
}}
onClose={onClose}
onOk={() => onClose()}
onClose={() => {
setAnchorPosition(undefined);
}}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
transformOrigin={{

View File

@ -9,9 +9,9 @@ import { getNode } from '$app/utils/document/node';
import { get } from '$app/utils/tool';
const headingBlockTopOffset: Record<number, string> = {
1: '1.65rem',
2: '1.3rem',
3: '0.25rem',
1: '0.4rem',
2: '0.2rem',
3: '0.15rem',
};
export function useBlockSideToolbar(id: string) {
@ -87,35 +87,6 @@ export function useBlockSideToolbar(id: string) {
};
}
function getNodeIdByPoint(x: number, y: number) {
const viewportNodes = document.querySelectorAll('[data-block-id]');
let node: {
el: Element;
rect: DOMRect;
} | null = null;
viewportNodes.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.x + rect.width - 1 >= x && rect.y + rect.height - 1 >= y && rect.y <= y) {
if (!node || rect.y > node.rect.y) {
node = {
el,
rect,
};
}
}
});
return node
? (
node as {
el: Element;
rect: DOMRect;
}
).el.getAttribute('data-block-id')
: null;
}
const transformOrigin: PopoverOrigin = {
vertical: 'bottom',
horizontal: 'left',

View File

@ -29,7 +29,7 @@ export default function BlockSideToolbar({ id }: { id: string }) {
opacity: show ? 1 : 0,
top: topOffset,
}}
className='absolute left-[-50px] inline-flex transition-opacity duration-100'
className='absolute left-[-50px] inline-flex'
>
{/** Add Block below */}
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>

View File

@ -292,7 +292,7 @@ function BlockSlashMenu({
<div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
{Object.entries(optionsByGroup).map(([group, options]) => (
<div key={group}>
<div className={'text-shade-3 px-2 py-2 text-sm'}>{group}</div>
<div className={'px-2 py-2 text-sm text-text-caption'}>{group}</div>
<div>
{options.map((option) => {
return (

View File

@ -90,7 +90,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
{...props}
ref={ref}
data-block-id={node.id}
className={`pt-[0.5px] ${className}`}
className={className}
>
{renderBlock()}
<BlockOverlay id={id} />

View File

@ -9,6 +9,8 @@ export const defaultTextActionItems = [
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
TextAction.TextColor,
TextAction.Highlight,
];
const groupKeys = {
comment: [],
@ -21,11 +23,20 @@ const groupKeys = {
TextAction.Equation,
],
link: [TextAction.Link],
color: [TextAction.TextColor, TextAction.Highlight],
turn: [TextAction.Turn],
};
export const multiLineTextActionProps: TextActionMenuProps = {
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
customItems: [
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.TextColor,
TextAction.Highlight,
],
};
export const multiLineTextActionGroups = [groupKeys.format];
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
export const multiLineTextActionGroups = [groupKeys.format, groupKeys.color];
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.color, groupKeys.link];

View File

@ -18,7 +18,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
style={{
opacity: 0,
}}
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-fill-toolbar leading-tight text-content-on-fill shadow-md transition-opacity duration-100'
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-fill-toolbar leading-tight text-content-on-fill shadow-md'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();

View File

@ -0,0 +1,101 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker';
import { FormatColorFill, FormatColorText } from '@mui/icons-material';
import { TextAction } from '$app/interfaces/document';
function BgColorPicker() {
const { t } = useTranslation();
const getColorIcon = useCallback((color: string) => {
return (
<div
style={{
backgroundColor: color,
}}
className={'rounded border border-line-divider p-0.5'}
>
<FormatColorText />
</div>
);
}, []);
const colors = useMemo(
() => [
{
name: t('colors.default'),
key: 'default',
color: 'transparent',
},
{
name: t('colors.custom'),
key: 'custom',
color: 'transparent',
},
{
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]
);
return (
<ColorPicker
getColorIcon={getColorIcon}
icon={
<FormatColorFill
sx={{
width: 18,
height: 18,
}}
/>
}
colors={colors}
format={TextAction.Highlight}
label={t('toolbar.highlight')}
/>
);
}
export default BgColorPicker;

View File

@ -0,0 +1,197 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { List } from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey';
import Popover from '@mui/material/Popover';
import Tooltip from '@mui/material/Tooltip';
import { useAppDispatch } from '$app/stores/store';
import { formatThunk, getFormatValuesThunk } from '$app_reducers/document/async-actions/format';
import { TextAction } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import CustomColorPicker from '$app/components/document/TextActionMenu/menu/CustomColorPicker';
export interface ColorItem {
name: string;
key: string;
color: string;
}
function ColorPicker({
label,
format,
colors,
icon,
getColorIcon,
}: {
format: TextAction;
label: string;
colors: ColorItem[];
icon: React.ReactNode;
getColorIcon: (color: string) => React.ReactNode;
}) {
const { controller, docId } = useSubscribeDocument();
const ref = useRef<HTMLDivElement>(null);
const [anchorPosition, setAnchorPosition] = useState<
| {
left: number;
top: number;
}
| undefined
>(undefined);
const open = Boolean(anchorPosition);
const dispatch = useAppDispatch();
const [customPickerAnchorPosition, setCustomPickerAnchorPosition] = useState<
| {
left: number;
top: number;
}
| undefined
>(undefined);
const customOpened = Boolean(customPickerAnchorPosition);
const [selectOption, setSelectOption] = useState<string | null>(null);
const [activeColor, setActiveColor] = useState<string | null>(null);
const openCustomColorPicker = useCallback(() => {
const target = document.querySelector('.color-item-custom') as Element;
const rect = target.getBoundingClientRect();
setCustomPickerAnchorPosition({
left: rect.left + rect.width + 10,
top: rect.top,
});
}, []);
useEffect(() => {
if (selectOption === 'custom') {
openCustomColorPicker();
} else {
setCustomPickerAnchorPosition(undefined);
}
}, [selectOption, openCustomColorPicker]);
const onOpen = useCallback(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
setAnchorPosition({
left: rect.left,
top: rect.top + rect.height + 10,
});
}, []);
const loadActiveColor = useCallback(async () => {
const { payload: formatValues } = (await dispatch(getFormatValuesThunk({ format, docId }))) as {
payload: Record<string, (boolean | string | undefined)[]>;
};
const multiLines = Object.keys(formatValues).length > 1;
const firstKey = Object.keys(formatValues)[0];
const firstValue = formatValues[firstKey].find((item) => item);
setActiveColor(multiLines ? null : String(firstValue));
}, [dispatch, docId, format]);
useEffect(() => {
void (async () => {
await loadActiveColor();
})();
}, [loadActiveColor]);
const formatColor = useCallback(
async (color: string | null) => {
await dispatch(formatThunk({ format, value: color, controller }));
setAnchorPosition(undefined);
await loadActiveColor();
},
[format, controller, dispatch, loadActiveColor]
);
const onClick = useCallback(async () => {
if (selectOption === 'custom') {
return;
}
if (selectOption === 'default') {
await formatColor(null);
} else {
const item = colors.find((color) => color.key === selectOption);
await formatColor(item?.color || null);
}
}, [selectOption, formatColor, colors]);
useBindArrowKey({
options: colors.map((item) => item.key),
onChange: (key) => {
setSelectOption(key);
},
selectOption,
onEnter: () => onClick(),
});
return (
<>
<div
ref={ref}
className={'cursor-pointer px-1.5 hover:text-fill-hover'}
onClick={onOpen}
style={{
color: activeColor || undefined,
}}
>
<Tooltip placement={'top-start'} disableInteractive title={label}>
<div>{icon}</div>
</Tooltip>
</div>
<Popover
onMouseDown={(e) => {
e.stopPropagation();
}}
disableAutoFocus={true}
disableRestoreFocus={true}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={open}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
onClose={() => setAnchorPosition(undefined)}
>
<List>
<div className={'w-[200px] px-4 py-2 uppercase text-text-caption'}>{label}</div>
{colors.map((item) => (
<MenuItem
className={`color-item-${item.key}`}
key={item.key}
onMouseEnter={() => {
setSelectOption(item.key);
}}
style={{
padding: '4px',
}}
selected={selectOption === item.key}
onClick={onClick}
>
<div className={'flex items-center'}>
{getColorIcon(item.color)}
<div className={'ml-2'}>{item.name}</div>
</div>
{item.key === 'custom' && (
<CustomColorPicker
open={customOpened}
onChange={formatColor}
anchorPosition={customPickerAnchorPosition}
onClose={() => {
setCustomPickerAnchorPosition(undefined);
}}
/>
)}
</MenuItem>
))}
</List>
</Popover>
</>
);
}
export default ColorPicker;

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import Popover from '@mui/material/Popover';
import { RGBColor, SketchPicker } from 'react-color';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { Divider } from '@mui/material';
function CustomColorPicker({
onChange,
open,
onClose,
anchorPosition,
}: {
open: boolean;
onChange: (color: string) => void;
anchorPosition?: {
left: number;
top: number;
};
onClose: () => void;
}) {
const { t } = useTranslation();
const [color, setColor] = useState<RGBColor | undefined>();
return (
<Popover
onMouseDown={(e) => e.stopPropagation()}
disableAutoFocus={true}
disableRestoreFocus={true}
sx={{
pointerEvents: 'none',
}}
PaperProps={{
style: {
pointerEvents: 'auto',
},
className: 'p-2',
}}
open={open}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
onClose={onClose}
>
<SketchPicker
onChange={(color, event) => {
setColor(color.rgb);
}}
color={color}
/>
<Divider />
<div className={'z-10 flex justify-end bg-bg-body px-2 pt-2'}>
<Button
onClick={() => {
onChange(`rgba(${color?.r}, ${color?.g}, ${color?.b}, ${color?.a})`);
}}
variant={'contained'}
>
{t('button.done')}
</Button>
</div>
</Popover>
);
}
export default CustomColorPicker;

View File

@ -29,7 +29,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
const { node: focusNode } = useSubscribeNode(focusId);
const [isActive, setIsActive] = React.useState(false);
const color = useMemo(() => (isActive ? 'text-content-on-fill-hover' : ''), [isActive]);
const color = useMemo(() => (isActive ? 'text-fill-hover' : ''), [isActive]);
const isFormatActive = useCallback(async () => {
if (!focusNode) return false;
@ -125,22 +125,18 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
return <StrikethroughSOutlined sx={iconSize} />;
case TextAction.Link:
return (
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
<LinkIcon
sx={{
fontSize: '1.2rem',
marginRight: '0.25rem',
}}
/>
<div className={'underline'}>{t('toolbar.link')}</div>
</div>
<LinkIcon
sx={{
fontSize: '1.2rem',
}}
/>
);
case TextAction.Equation:
return <Functions sx={iconSize} />;
default:
return null;
}
}, [icon, t]);
}, [icon]);
return (
<ToolbarTooltip title={formatTooltips[format]}>

View File

@ -0,0 +1,97 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { TextAction } from '$app/interfaces/document';
import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker';
import { FormatColorText } from '@mui/icons-material';
function TextColorPicker() {
const { t } = useTranslation();
const getColorIcon = useCallback((color: string) => {
return (
<div className={'rounded border border-line-divider p-0.5'}>
<FormatColorText style={{ color }} />
</div>
);
}, []);
const colors = useMemo(
() => [
{
name: t('colors.default'),
key: 'default',
color: 'var(--text-title)',
},
{
name: t('colors.custom'),
key: 'custom',
color: 'var(--text-title)',
},
{
key: 'gray',
name: t('colors.gray'),
color: '#546e7a',
},
{
key: 'brown',
name: t('colors.brown'),
color: '#795548',
},
{
key: 'orange',
name: t('colors.orange'),
color: '#ff5722',
},
{
key: 'yellow',
name: t('colors.yellow'),
color: '#ffff00',
},
{
key: 'green',
name: t('colors.green'),
color: '#4caf50',
},
{
key: 'blue',
name: t('colors.blue'),
color: '#0d47a1',
},
{
key: 'purple',
name: t('colors.purple'),
color: '#9c27b0',
},
{
key: 'pink',
name: t('colors.pink'),
color: '#d81b60',
},
{
key: 'red',
name: t('colors.red'),
color: '#b71c1c',
},
],
[t]
);
return (
<ColorPicker
icon={
<FormatColorText
sx={{
width: 18,
height: 18,
}}
/>
}
getColorIcon={getColorIcon}
colors={colors}
format={TextAction.TextColor}
label={t('toolbar.color')}
/>
);
}
export default TextColorPicker;

View File

@ -3,6 +3,8 @@ import React, { useCallback } from 'react';
import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
import TextColorPicker from '$app/components/document/TextActionMenu/menu/TextColorPicker';
import BgColorPicker from '$app/components/document/TextActionMenu/menu/BgColorPicker';
function TextActionMenuList() {
const { groupItems, isSingleLine, focusId } = useTextActionMenu();
@ -19,6 +21,10 @@ function TextActionMenuList() {
case TextAction.Code:
case TextAction.Equation:
return <FormatButton format={action} icon={action} />;
case TextAction.TextColor:
return <TextColorPicker />;
case TextAction.Highlight:
return <BgColorPicker />;
default:
return null;
}

View File

@ -49,7 +49,7 @@ export default function VirtualizedList({
const id = childIds[virtualRow.index];
return (
<div key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
<div className={'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
{renderNode(id)}
</div>

View File

@ -1,5 +1,5 @@
import React, { forwardRef, MouseEvent, useMemo } from 'react';
import { ListItemButton } from '@mui/material';
import { MenuItem as MuiMenuItem } from '@mui/material';
const MenuItem = forwardRef(function (
{
@ -34,14 +34,16 @@ const MenuItem = forwardRef(function (
return (
<div className={className} ref={ref} id={id}>
<ListItemButton
<MuiMenuItem
sx={{
borderRadius: '4px',
padding: '4px 8px',
fontSize: 14,
}}
selected={isHovered}
onMouseEnter={(e) => onHover?.(e)}
onMouseEnter={(e) => {
onHover?.(e);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -53,7 +55,7 @@ const MenuItem = forwardRef(function (
width: imgSize.width,
height: imgSize.height,
}}
className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
className={`mr-2 flex items-center justify-center rounded border border-line-divider`}
>
{icon}
</div>
@ -61,7 +63,7 @@ const MenuItem = forwardRef(function (
<div className={'text-sm'}>{title}</div>
{desc && (
<div
className={'font-normal text-shade-4'}
className={'font-normal text-text-caption'}
style={{
fontSize: '0.85em',
fontWeight: 300,
@ -72,7 +74,7 @@ const MenuItem = forwardRef(function (
)}
</div>
<div>{extra}</div>
</ListItemButton>
</MuiMenuItem>
</div>
);
});

View File

@ -21,6 +21,8 @@ interface Attributes {
link_placeholder?: string;
temporary?: boolean;
formula?: string;
font_color?: string;
bg_color?: string;
}
interface TextLeafProps extends RenderLeafProps {
leaf: BaseText & Attributes;
@ -122,7 +124,15 @@ const TextLeaf = (props: TextLeafProps) => {
}
return (
<span ref={ref} {...customAttributes} className={className.join(' ')}>
<span
style={{
backgroundColor: leaf.bg_color,
color: leaf.font_color,
}}
ref={ref}
{...customAttributes}
className={className.join(' ')}
>
{newChildren}
</span>
);

View File

@ -4,6 +4,7 @@ import Tooltip from '@mui/material/Tooltip';
function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
return (
<Tooltip
disableInteractive
slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
title={title}
placement='top-start'

View File

@ -31,10 +31,12 @@ interface Option {
const TurnIntoPopover = ({
id,
onClose,
onOk,
...props
}: {
id: string;
onClose?: () => void;
onOk?: () => void;
} & PopoverProps) => {
const { node } = useSubscribeNode(id);
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
@ -142,8 +144,9 @@ const TurnIntoPopover = ({
const isSelected = getSelected(option);
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
onOk?.();
},
[getSelected, turnIntoBlock]
[onOk, getSelected, turnIntoBlock]
);
const onKeyDown = useCallback(

View File

@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react';
import { Keyboard } from '$app/constants/document/keyboard';
export const useBindArrowKey = ({
options,
onLeft,
onRight,
onEnter,
onChange,
selectOption,
}: {
options: string[];
onLeft?: () => void;
onRight?: () => void;
onEnter?: () => void;
onChange?: (key: string) => void;
selectOption?: string | null;
}) => {
const onUp = useCallback(() => {
const getSelected = () => {
const index = options.findIndex((item) => item === selectOption);
if (index === -1) return options[0];
const length = options.length;
return options[(index + length - 1) % length];
};
onChange?.(getSelected());
}, [onChange, options, selectOption]);
const onDown = useCallback(() => {
const getSelected = () => {
const index = options.findIndex((item) => item === selectOption);
if (index === -1) return options[0];
const length = options.length;
return options[(index + 1) % length];
};
onChange?.(getSelected());
}, [onChange, options, selectOption]);
const handleArrowKey = useCallback(
(e: KeyboardEvent) => {
if (
[Keyboard.keys.UP, Keyboard.keys.DOWN, Keyboard.keys.LEFT, Keyboard.keys.RIGHT, Keyboard.keys.ENTER].includes(
e.key
)
) {
e.stopPropagation();
e.preventDefault();
}
if (e.key === Keyboard.keys.UP) {
onUp();
} else if (e.key === Keyboard.keys.DOWN) {
onDown();
} else if (e.key === Keyboard.keys.LEFT) {
onLeft?.();
} else if (e.key === Keyboard.keys.RIGHT) {
onRight?.();
} else if (e.key === Keyboard.keys.ENTER) {
onEnter?.();
}
},
[onDown, onEnter, onLeft, onRight, onUp]
);
useEffect(() => {
document.addEventListener('keydown', handleArrowKey, true);
return () => {
document.removeEventListener('keydown', handleArrowKey, true);
};
}, [handleArrowKey]);
};

View File

@ -235,6 +235,8 @@ export enum TextAction {
Code = 'code',
Equation = 'formula',
Link = 'href',
TextColor = 'font_color',
Highlight = 'bg_color',
}
export interface TextActionMenuProps {
/**

View File

@ -5,6 +5,35 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import Delta from 'quill-delta';
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
type FormatValues = Record<string, (boolean | string | undefined)[]>;
export const getFormatValuesThunk = createAsyncThunk(
'document/getFormatValues',
({ docId, format }: { docId: string; format: TextAction }, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const document = state[DOCUMENT_NAME][docId];
const documentRange = state[RANGE_NAME][docId];
const { ranges } = documentRange;
const mapAttrs = (delta: Delta, format: TextAction) => {
return delta.ops.map((op) => op.attributes?.[format] as boolean | string | undefined);
};
const formatValues: FormatValues = {};
Object.entries(ranges).forEach(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const rangeDelta = delta.slice(index, index + length);
formatValues[id] = mapAttrs(rangeDelta, format);
});
return formatValues;
}
);
export const getFormatActiveThunk = createAsyncThunk<
boolean,
{
@ -12,30 +41,22 @@ export const getFormatActiveThunk = createAsyncThunk<
docId: string;
}
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const document = state[DOCUMENT_NAME][docId];
const documentRange = state[RANGE_NAME][docId];
const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format]);
const { dispatch } = thunkAPI;
const { payload } = (await dispatch(getFormatValuesThunk({ docId, format }))) as {
payload: FormatValues;
};
return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const rangeDelta = delta.slice(index, index + length);
return match(rangeDelta, format);
return Object.values(payload).every((values) => {
return values.every((value) => {
return value !== undefined;
});
});
});
export const toggleFormatThunk = createAsyncThunk(
'document/toggleFormat',
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const { dispatch } = thunkAPI;
const { format, controller } = payload;
const docId = controller.documentId;
let isActive = payload.isActive;
@ -51,38 +72,30 @@ export const toggleFormatThunk = createAsyncThunk(
isActive = !!active;
}
const formatValue = isActive ? undefined : true;
const formatValue = isActive ? null : true;
await dispatch(formatThunk({ format, value: formatValue, controller }));
}
);
export const formatThunk = createAsyncThunk(
'document/format',
async (payload: { format: TextAction; value: string | boolean | null; controller: DocumentController }, thunkAPI) => {
const { getState } = thunkAPI;
const { format, controller, value } = payload;
const docId = controller.documentId;
const state = getState() as RootState;
const document = state[DOCUMENT_NAME][docId];
const documentRange = state[RANGE_NAME][docId];
const { ranges } = documentRange;
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
const newOps = delta.ops.map((op) => {
const attributes = {
...op.attributes,
[format]: value,
};
return {
insert: op.insert,
attributes: attributes,
};
});
return new Delta(newOps);
};
const actions = Object.entries(ranges).map(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const beforeDelta = delta.slice(0, index);
const afterDelta = delta.slice(index + length);
const rangeDelta = delta.slice(index, index + length);
const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
const diffDelta: Delta = new Delta();
diffDelta.retain(index).retain(length, { [format]: value });
const newDelta = delta.compose(diffDelta);
return controller.getUpdateAction({
...node,

View File

@ -47,4 +47,19 @@ th {
span[data-slate-placeholder="true"]:not(.inline-block-content) {
@apply text-text-placeholder;
opacity: 1 !important;
}
.sketch-picker {
background-color: var(--bg-body) !important;
border-color: transparent !important;
box-shadow: none !important;
}
.sketch-picker .flexbox-fix {
border-color: var(--line-divider) !important;
}
.sketch-picker [id^='rc-editable-input'] {
background-color: var(--bg-body) !important;
border-color: var(--line-divider) !important;
color: var(--text-title) !important;
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
}

View File

@ -608,5 +608,18 @@
"views": {
"deleteContentTitle": "Are you sure want to delete the {pageType}?",
"deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash."
},
"colors": {
"custom": "Custom",
"default": "Default",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple",
"pink": "Pink",
"brown": "Brown",
"gray": "Gray"
}
}