mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support editor format text color and bg color (#3061)
This commit is contained in:
parent
915ce02157
commit
a885170869
@ -46,6 +46,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-calendar": "^4.1.0",
|
"react-calendar": "^4.1.0",
|
||||||
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-i18next": "^12.2.0",
|
"react-i18next": "^12.2.0",
|
||||||
@ -72,6 +73,7 @@
|
|||||||
"@types/quill": "^2.0.10",
|
"@types/quill": "^2.0.10",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-beautiful-dnd": "^13.1.3",
|
"@types/react-beautiful-dnd": "^13.1.3",
|
||||||
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-katex": "^3.0.0",
|
"@types/react-katex": "^3.0.0",
|
||||||
"@types/react-transition-group": "^4.4.6",
|
"@types/react-transition-group": "^4.4.6",
|
||||||
|
60
frontend/appflowy_tauri/pnpm-lock.yaml
generated
60
frontend/appflowy_tauri/pnpm-lock.yaml
generated
@ -88,6 +88,9 @@ dependencies:
|
|||||||
react-calendar:
|
react-calendar:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.2.1(react-dom@18.2.0)(react@18.2.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:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
@ -162,6 +165,9 @@ devDependencies:
|
|||||||
'@types/react-beautiful-dnd':
|
'@types/react-beautiful-dnd':
|
||||||
specifier: ^13.1.3
|
specifier: ^13.1.3
|
||||||
version: 13.1.4
|
version: 13.1.4
|
||||||
|
'@types/react-color':
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.0.6
|
specifier: ^18.0.6
|
||||||
version: 18.2.4
|
version: 18.2.4
|
||||||
@ -960,6 +966,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
||||||
dev: true
|
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:
|
/@istanbuljs/load-nyc-config@1.1.0:
|
||||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1701,6 +1715,13 @@ packages:
|
|||||||
'@types/react': 18.2.6
|
'@types/react': 18.2.6
|
||||||
dev: true
|
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:
|
/@types/react-dom@18.2.4:
|
||||||
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
|
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1747,6 +1768,12 @@ packages:
|
|||||||
'@types/scheduler': 0.16.3
|
'@types/scheduler': 0.16.3
|
||||||
csstype: 3.1.2
|
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:
|
/@types/scheduler@0.16.3:
|
||||||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
||||||
|
|
||||||
@ -3975,6 +4002,10 @@ packages:
|
|||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lodash-es@4.17.21:
|
||||||
|
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.clonedeep@4.5.0:
|
/lodash.clonedeep@4.5.0:
|
||||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
@ -4035,6 +4066,10 @@ packages:
|
|||||||
tmpl: 1.0.5
|
tmpl: 1.0.5
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/material-colors@1.2.6:
|
||||||
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/memoize-one@5.2.1:
|
/memoize-one@5.2.1:
|
||||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -4593,6 +4628,21 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
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):
|
/react-dom@18.2.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4770,6 +4820,15 @@ packages:
|
|||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
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:
|
/read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -5230,7 +5289,6 @@ packages:
|
|||||||
|
|
||||||
/tinycolor2@1.6.0:
|
/tinycolor2@1.6.0:
|
||||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/tmpl@1.0.5:
|
/tmpl@1.0.5:
|
||||||
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
|
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
|
||||||
|
@ -40,9 +40,6 @@ function BlockDraggable(
|
|||||||
data-draggable-type={type}
|
data-draggable-type={type}
|
||||||
onMouseDown={getAnchorEl ? undefined : onDragStart}
|
onMouseDown={getAnchorEl ? undefined : onDragStart}
|
||||||
className={`relative ${className || ''}`}
|
className={`relative ${className || ''}`}
|
||||||
style={{
|
|
||||||
opacity: isDragging ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
@ -143,7 +143,7 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<BlockMenuTurnInto
|
<BlockMenuTurnInto
|
||||||
key={option.key}
|
key={option.key}
|
||||||
lable={option.title}
|
label={option.title}
|
||||||
onHovered={() => {
|
onHovered={() => {
|
||||||
setHovered(BlockMenuOption.TurnInto);
|
setHovered(BlockMenuOption.TurnInto);
|
||||||
setSubMenuOpened(true);
|
setSubMenuOpened(true);
|
||||||
|
@ -9,14 +9,14 @@ function BlockMenuTurnInto({
|
|||||||
onHovered,
|
onHovered,
|
||||||
isHovered,
|
isHovered,
|
||||||
menuOpened,
|
menuOpened,
|
||||||
lable,
|
label,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onHovered: (e: MouseEvent) => void;
|
onHovered: (e: MouseEvent) => void;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
menuOpened: boolean;
|
menuOpened: boolean;
|
||||||
lable?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
|
const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
|
||||||
@ -39,7 +39,7 @@ function BlockMenuTurnInto({
|
|||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title={lable}
|
title={label}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
icon={<Transform />}
|
icon={<Transform />}
|
||||||
extra={<ArrowRight />}
|
extra={<ArrowRight />}
|
||||||
@ -60,7 +60,10 @@ function BlockMenuTurnInto({
|
|||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClose={onClose}
|
onOk={() => onClose()}
|
||||||
|
onClose={() => {
|
||||||
|
setAnchorPosition(undefined);
|
||||||
|
}}
|
||||||
anchorReference={'anchorPosition'}
|
anchorReference={'anchorPosition'}
|
||||||
anchorPosition={anchorPosition}
|
anchorPosition={anchorPosition}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
|
@ -9,9 +9,9 @@ import { getNode } from '$app/utils/document/node';
|
|||||||
import { get } from '$app/utils/tool';
|
import { get } from '$app/utils/tool';
|
||||||
|
|
||||||
const headingBlockTopOffset: Record<number, string> = {
|
const headingBlockTopOffset: Record<number, string> = {
|
||||||
1: '1.65rem',
|
1: '0.4rem',
|
||||||
2: '1.3rem',
|
2: '0.2rem',
|
||||||
3: '0.25rem',
|
3: '0.15rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useBlockSideToolbar(id: string) {
|
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 = {
|
const transformOrigin: PopoverOrigin = {
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
|
@ -29,7 +29,7 @@ export default function BlockSideToolbar({ id }: { id: string }) {
|
|||||||
opacity: show ? 1 : 0,
|
opacity: show ? 1 : 0,
|
||||||
top: topOffset,
|
top: topOffset,
|
||||||
}}
|
}}
|
||||||
className='absolute left-[-50px] inline-flex transition-opacity duration-100'
|
className='absolute left-[-50px] inline-flex'
|
||||||
>
|
>
|
||||||
{/** Add Block below */}
|
{/** Add Block below */}
|
||||||
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>
|
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>
|
||||||
|
@ -292,7 +292,7 @@ function BlockSlashMenu({
|
|||||||
<div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
|
<div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||||
{Object.entries(optionsByGroup).map(([group, options]) => (
|
{Object.entries(optionsByGroup).map(([group, options]) => (
|
||||||
<div key={group}>
|
<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>
|
<div>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
return (
|
return (
|
||||||
|
@ -90,7 +90,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-block-id={node.id}
|
data-block-id={node.id}
|
||||||
className={`pt-[0.5px] ${className}`}
|
className={className}
|
||||||
>
|
>
|
||||||
{renderBlock()}
|
{renderBlock()}
|
||||||
<BlockOverlay id={id} />
|
<BlockOverlay id={id} />
|
||||||
|
@ -9,6 +9,8 @@ export const defaultTextActionItems = [
|
|||||||
TextAction.Strikethrough,
|
TextAction.Strikethrough,
|
||||||
TextAction.Code,
|
TextAction.Code,
|
||||||
TextAction.Equation,
|
TextAction.Equation,
|
||||||
|
TextAction.TextColor,
|
||||||
|
TextAction.Highlight,
|
||||||
];
|
];
|
||||||
const groupKeys = {
|
const groupKeys = {
|
||||||
comment: [],
|
comment: [],
|
||||||
@ -21,11 +23,20 @@ const groupKeys = {
|
|||||||
TextAction.Equation,
|
TextAction.Equation,
|
||||||
],
|
],
|
||||||
link: [TextAction.Link],
|
link: [TextAction.Link],
|
||||||
|
color: [TextAction.TextColor, TextAction.Highlight],
|
||||||
turn: [TextAction.Turn],
|
turn: [TextAction.Turn],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const multiLineTextActionProps: TextActionMenuProps = {
|
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 multiLineTextActionGroups = [groupKeys.format, groupKeys.color];
|
||||||
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
|
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.color, groupKeys.link];
|
||||||
|
@ -18,7 +18,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
|||||||
style={{
|
style={{
|
||||||
opacity: 0,
|
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) => {
|
onMouseDown={(e) => {
|
||||||
// prevent toolbar from taking focus away from editor
|
// prevent toolbar from taking focus away from editor
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -29,7 +29,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
const { node: focusNode } = useSubscribeNode(focusId);
|
const { node: focusNode } = useSubscribeNode(focusId);
|
||||||
|
|
||||||
const [isActive, setIsActive] = React.useState(false);
|
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 () => {
|
const isFormatActive = useCallback(async () => {
|
||||||
if (!focusNode) return false;
|
if (!focusNode) return false;
|
||||||
@ -125,22 +125,18 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
return <StrikethroughSOutlined sx={iconSize} />;
|
return <StrikethroughSOutlined sx={iconSize} />;
|
||||||
case TextAction.Link:
|
case TextAction.Link:
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
|
|
||||||
<LinkIcon
|
<LinkIcon
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '1.2rem',
|
fontSize: '1.2rem',
|
||||||
marginRight: '0.25rem',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={'underline'}>{t('toolbar.link')}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
case TextAction.Equation:
|
case TextAction.Equation:
|
||||||
return <Functions sx={iconSize} />;
|
return <Functions sx={iconSize} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [icon, t]);
|
}, [icon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarTooltip title={formatTooltips[format]}>
|
<ToolbarTooltip title={formatTooltips[format]}>
|
||||||
|
@ -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;
|
@ -3,6 +3,8 @@ import React, { useCallback } from 'react';
|
|||||||
import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
|
import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
|
||||||
import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
|
import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
|
||||||
import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
|
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() {
|
function TextActionMenuList() {
|
||||||
const { groupItems, isSingleLine, focusId } = useTextActionMenu();
|
const { groupItems, isSingleLine, focusId } = useTextActionMenu();
|
||||||
@ -19,6 +21,10 @@ function TextActionMenuList() {
|
|||||||
case TextAction.Code:
|
case TextAction.Code:
|
||||||
case TextAction.Equation:
|
case TextAction.Equation:
|
||||||
return <FormatButton format={action} icon={action} />;
|
return <FormatButton format={action} icon={action} />;
|
||||||
|
case TextAction.TextColor:
|
||||||
|
return <TextColorPicker />;
|
||||||
|
case TextAction.Highlight:
|
||||||
|
return <BgColorPicker />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ export default function VirtualizedList({
|
|||||||
const id = childIds[virtualRow.index];
|
const id = childIds[virtualRow.index];
|
||||||
|
|
||||||
return (
|
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}
|
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
||||||
{renderNode(id)}
|
{renderNode(id)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { forwardRef, MouseEvent, useMemo } from 'react';
|
import React, { forwardRef, MouseEvent, useMemo } from 'react';
|
||||||
import { ListItemButton } from '@mui/material';
|
import { MenuItem as MuiMenuItem } from '@mui/material';
|
||||||
|
|
||||||
const MenuItem = forwardRef(function (
|
const MenuItem = forwardRef(function (
|
||||||
{
|
{
|
||||||
@ -34,14 +34,16 @@ const MenuItem = forwardRef(function (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} ref={ref} id={id}>
|
<div className={className} ref={ref} id={id}>
|
||||||
<ListItemButton
|
<MuiMenuItem
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
selected={isHovered}
|
selected={isHovered}
|
||||||
onMouseEnter={(e) => onHover?.(e)}
|
onMouseEnter={(e) => {
|
||||||
|
onHover?.(e);
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -53,7 +55,7 @@ const MenuItem = forwardRef(function (
|
|||||||
width: imgSize.width,
|
width: imgSize.width,
|
||||||
height: imgSize.height,
|
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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +63,7 @@ const MenuItem = forwardRef(function (
|
|||||||
<div className={'text-sm'}>{title}</div>
|
<div className={'text-sm'}>{title}</div>
|
||||||
{desc && (
|
{desc && (
|
||||||
<div
|
<div
|
||||||
className={'font-normal text-shade-4'}
|
className={'font-normal text-text-caption'}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.85em',
|
fontSize: '0.85em',
|
||||||
fontWeight: 300,
|
fontWeight: 300,
|
||||||
@ -72,7 +74,7 @@ const MenuItem = forwardRef(function (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>{extra}</div>
|
<div>{extra}</div>
|
||||||
</ListItemButton>
|
</MuiMenuItem>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -21,6 +21,8 @@ interface Attributes {
|
|||||||
link_placeholder?: string;
|
link_placeholder?: string;
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
formula?: string;
|
formula?: string;
|
||||||
|
font_color?: string;
|
||||||
|
bg_color?: string;
|
||||||
}
|
}
|
||||||
interface TextLeafProps extends RenderLeafProps {
|
interface TextLeafProps extends RenderLeafProps {
|
||||||
leaf: BaseText & Attributes;
|
leaf: BaseText & Attributes;
|
||||||
@ -122,7 +124,15 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{newChildren}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -4,6 +4,7 @@ import Tooltip from '@mui/material/Tooltip';
|
|||||||
function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
|
function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
disableInteractive
|
||||||
slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
|
slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
|
||||||
title={title}
|
title={title}
|
||||||
placement='top-start'
|
placement='top-start'
|
||||||
|
@ -31,10 +31,12 @@ interface Option {
|
|||||||
const TurnIntoPopover = ({
|
const TurnIntoPopover = ({
|
||||||
id,
|
id,
|
||||||
onClose,
|
onClose,
|
||||||
|
onOk,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onOk?: () => void;
|
||||||
} & PopoverProps) => {
|
} & PopoverProps) => {
|
||||||
const { node } = useSubscribeNode(id);
|
const { node } = useSubscribeNode(id);
|
||||||
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
|
const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
|
||||||
@ -142,8 +144,9 @@ const TurnIntoPopover = ({
|
|||||||
const isSelected = getSelected(option);
|
const isSelected = getSelected(option);
|
||||||
|
|
||||||
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
|
option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
|
||||||
|
onOk?.();
|
||||||
},
|
},
|
||||||
[getSelected, turnIntoBlock]
|
[onOk, getSelected, turnIntoBlock]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
|
@ -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]);
|
||||||
|
};
|
@ -235,6 +235,8 @@ export enum TextAction {
|
|||||||
Code = 'code',
|
Code = 'code',
|
||||||
Equation = 'formula',
|
Equation = 'formula',
|
||||||
Link = 'href',
|
Link = 'href',
|
||||||
|
TextColor = 'font_color',
|
||||||
|
Highlight = 'bg_color',
|
||||||
}
|
}
|
||||||
export interface TextActionMenuProps {
|
export interface TextActionMenuProps {
|
||||||
/**
|
/**
|
||||||
|
@ -5,6 +5,35 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
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<
|
export const getFormatActiveThunk = createAsyncThunk<
|
||||||
boolean,
|
boolean,
|
||||||
{
|
{
|
||||||
@ -12,30 +41,22 @@ export const getFormatActiveThunk = createAsyncThunk<
|
|||||||
docId: string;
|
docId: string;
|
||||||
}
|
}
|
||||||
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
|
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
|
||||||
const { getState } = thunkAPI;
|
const { dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const { payload } = (await dispatch(getFormatValuesThunk({ docId, format }))) as {
|
||||||
const document = state[DOCUMENT_NAME][docId];
|
payload: FormatValues;
|
||||||
const documentRange = state[RANGE_NAME][docId];
|
|
||||||
const { ranges } = documentRange;
|
|
||||||
const match = (delta: Delta, format: TextAction) => {
|
|
||||||
return delta.ops.every((op) => op.attributes?.[format]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return Object.entries(ranges).every(([id, range]) => {
|
return Object.values(payload).every((values) => {
|
||||||
const node = document.nodes[id];
|
return values.every((value) => {
|
||||||
const delta = new Delta(node.data?.delta);
|
return value !== undefined;
|
||||||
const index = range?.index || 0;
|
});
|
||||||
const length = range?.length || 0;
|
|
||||||
const rangeDelta = delta.slice(index, index + length);
|
|
||||||
|
|
||||||
return match(rangeDelta, format);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toggleFormatThunk = createAsyncThunk(
|
export const toggleFormatThunk = createAsyncThunk(
|
||||||
'document/toggleFormat',
|
'document/toggleFormat',
|
||||||
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
|
async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { dispatch } = thunkAPI;
|
||||||
const { format, controller } = payload;
|
const { format, controller } = payload;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
let isActive = payload.isActive;
|
let isActive = payload.isActive;
|
||||||
@ -51,38 +72,30 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
isActive = !!active;
|
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 state = getState() as RootState;
|
||||||
const document = state[DOCUMENT_NAME][docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state[RANGE_NAME][docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
const { ranges } = documentRange;
|
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 actions = Object.entries(ranges).map(([id, range]) => {
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
const delta = new Delta(node.data?.delta);
|
const delta = new Delta(node.data?.delta);
|
||||||
const index = range?.index || 0;
|
const index = range?.index || 0;
|
||||||
const length = range?.length || 0;
|
const length = range?.length || 0;
|
||||||
const beforeDelta = delta.slice(0, index);
|
const diffDelta: Delta = new Delta();
|
||||||
const afterDelta = delta.slice(index + length);
|
diffDelta.retain(index).retain(length, { [format]: value });
|
||||||
const rangeDelta = delta.slice(index, index + length);
|
const newDelta = delta.compose(diffDelta);
|
||||||
const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
|
|
||||||
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
|
|
||||||
|
|
||||||
return controller.getUpdateAction({
|
return controller.getUpdateAction({
|
||||||
...node,
|
...node,
|
||||||
|
@ -48,3 +48,18 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
|||||||
@apply text-text-placeholder;
|
@apply text-text-placeholder;
|
||||||
opacity: 1 !important;
|
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;
|
||||||
|
}
|
@ -608,5 +608,18 @@
|
|||||||
"views": {
|
"views": {
|
||||||
"deleteContentTitle": "Are you sure want to delete the {pageType}?",
|
"deleteContentTitle": "Are you sure want to delete the {pageType}?",
|
||||||
"deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user