mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Support text block add link
(#2730)
* feat: support text block href attribute * fix: double click didn't select range * fix: link update * chore: ts lint * chore: add new line Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io> * chore: update get word indices function --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
parent
aca3c90eb3
commit
00c0934df6
@ -30,6 +30,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
dispatch(rangeActions.clearRange());
|
dispatch(rangeActions.clearRange());
|
||||||
|
setForward(true);
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// display caret color
|
// display caret color
|
||||||
@ -85,7 +86,6 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
// reset the range
|
|
||||||
reset();
|
reset();
|
||||||
// skip if the target is not a block
|
// skip if the target is not a block
|
||||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||||
@ -150,6 +150,8 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
setFocus(null);
|
||||||
|
anchorRef.current = null;
|
||||||
dispatch(rangeActions.setDragging(false));
|
dispatch(rangeActions.setDragging(false));
|
||||||
}, [dispatch, isDragging]);
|
}, [dispatch, isDragging]);
|
||||||
|
|
||||||
@ -164,7 +166,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
document.removeEventListener('mouseup', handleDragEnd);
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
container.removeEventListener('keydown', onKeyDown, true);
|
container.removeEventListener('keydown', onKeyDown, true);
|
||||||
};
|
};
|
||||||
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
}, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,9 @@ export function useRangeKeyDown() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { anchor, focus } = rangeRef.current;
|
const { anchor, focus } = rangeRef.current;
|
||||||
if (anchor?.id === focus?.id) {
|
if (!anchor || !focus) return;
|
||||||
|
|
||||||
|
if (anchor.id === focus.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -131,7 +133,9 @@ export function useRangeKeyDown() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lastEvent = filteredEvents[lastIndex];
|
const lastEvent = filteredEvents[lastIndex];
|
||||||
lastEvent?.handler(e);
|
if (!lastEvent) return;
|
||||||
|
e.preventDefault();
|
||||||
|
lastEvent.handler(e);
|
||||||
},
|
},
|
||||||
[interceptEvents, rangeRef]
|
[interceptEvents, rangeRef]
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,7 @@ import { useChange } from '$app/components/document/_shared/EditorHooks/useChang
|
|||||||
import { useKeyDown } from './useKeyDown';
|
import { useKeyDown } from './useKeyDown';
|
||||||
import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
|
import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
|
||||||
import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
|
import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
|
||||||
|
import { useSubscribeDecorate } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
|
||||||
export default function CodeBlock({
|
export default function CodeBlock({
|
||||||
node,
|
node,
|
||||||
@ -16,7 +17,8 @@ export default function CodeBlock({
|
|||||||
const onKeyDown = useKeyDown(id);
|
const onKeyDown = useKeyDown(id);
|
||||||
const className = props.className ? ` ${props.className}` : '';
|
const className = props.className ? ` ${props.className}` : '';
|
||||||
const { value, onChange } = useChange(node);
|
const { value, onChange } = useChange(node);
|
||||||
const { onSelectionChange, selection, lastSelection } = useSelection(id);
|
const selectionProps = useSelection(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
|
<div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
|
||||||
<div className={'mb-2 w-[100%]'}>
|
<div className={'mb-2 w-[100%]'}>
|
||||||
@ -28,9 +30,7 @@ export default function CodeBlock({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
language={language}
|
language={language}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onSelectionChange={onSelectionChange}
|
{...selectionProps}
|
||||||
selection={selection}
|
|
||||||
lastSelection={lastSelection}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -16,6 +16,7 @@ import DividerBlock from '$app/components/document/DividerBlock';
|
|||||||
import CalloutBlock from '$app/components/document/CalloutBlock';
|
import CalloutBlock from '$app/components/document/CalloutBlock';
|
||||||
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
|
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
|
||||||
import CodeBlock from '$app/components/document/CodeBlock';
|
import CodeBlock from '$app/components/document/CodeBlock';
|
||||||
|
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
|
||||||
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { node, childIds, isSelected, ref } = useNode(id);
|
const { node, childIds, isSelected, ref } = useNode(id);
|
||||||
@ -60,13 +61,15 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
|||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
|
<NodeIdContext.Provider value={id}>
|
||||||
{renderBlock()}
|
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
|
||||||
<BlockOverlay id={id} />
|
{renderBlock()}
|
||||||
{isSelected ? (
|
<BlockOverlay id={id} />
|
||||||
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
|
{isSelected ? (
|
||||||
) : null}
|
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
</NodeIdContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import TextActionMenu from '$app/components/document/TextActionMenu';
|
|||||||
import BlockSlash from '$app/components/document/BlockSlash';
|
import BlockSlash from '$app/components/document/BlockSlash';
|
||||||
import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
|
import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
|
||||||
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
|
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
|
||||||
|
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
|
||||||
|
|
||||||
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||||
useCopy(container);
|
useCopy(container);
|
||||||
@ -15,6 +16,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
|
|||||||
<TextActionMenu container={container} />
|
<TextActionMenu container={container} />
|
||||||
<BlockSelection container={container} />
|
<BlockSelection container={container} />
|
||||||
<BlockSlash />
|
<BlockSlash />
|
||||||
|
<LinkEditPopover />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import { debounce } from '$app/utils/tool';
|
|||||||
|
|
||||||
export function useMenuStyle(container: HTMLDivElement) {
|
export function useMenuStyle(container: HTMLDivElement) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const id = useAppSelector((state) => state.documentRange.focus?.id);
|
const id = useAppSelector((state) => state.documentRange.caret?.id);
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
|
||||||
const reCalculatePosition = useCallback(() => {
|
const reCalculatePosition = useCallback(() => {
|
||||||
|
@ -2,6 +2,7 @@ import { useMenuStyle } from './index.hooks';
|
|||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
|
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
|
||||||
import BlockPortal from '$app/components/document/BlockPortal';
|
import BlockPortal from '$app/components/document/BlockPortal';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
||||||
const { ref, id } = useMenuStyle(container);
|
const { ref, id } = useMenuStyle(container);
|
||||||
@ -14,7 +15,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-[#333] leading-tight shadow-lg transition-opacity duration-100'
|
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-black leading-tight text-white shadow-lg transition-opacity duration-100'
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// prevent toolbar from taking focus away from editor
|
// prevent toolbar from taking focus away from editor
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -27,16 +28,24 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
||||||
const canShow = useAppSelector((state) => {
|
const range = useAppSelector((state) => state.documentRange);
|
||||||
const { isDragging, focus, anchor, ranges } = state.documentRange;
|
const canShow = useMemo(() => {
|
||||||
|
const { isDragging, focus, anchor, ranges, caret } = range;
|
||||||
|
// don't show if dragging
|
||||||
if (isDragging) return false;
|
if (isDragging) return false;
|
||||||
if (!focus || !anchor) return false;
|
// don't show if no focus or anchor
|
||||||
const isSameLine = anchor.id === focus.id;
|
if (!caret) return false;
|
||||||
const anchorRange = ranges[anchor.id];
|
const isSameLine = anchor?.id === focus?.id;
|
||||||
if (!anchorRange) return false;
|
|
||||||
const isCollapsed = isSameLine && anchorRange.length === 0;
|
// show toolbar if range has multiple nodes
|
||||||
return !isCollapsed;
|
if (!isSameLine) return true;
|
||||||
});
|
const caretRange = ranges[caret.id];
|
||||||
|
// don't show if no caret range
|
||||||
|
if (!caretRange) return false;
|
||||||
|
// show toolbar if range is not collapsed
|
||||||
|
return caretRange.length > 0;
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
if (!canShow) return null;
|
if (!canShow) return null;
|
||||||
|
|
||||||
return <TextActionComponent container={container} />;
|
return <TextActionComponent container={container} />;
|
||||||
|
@ -7,6 +7,7 @@ import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/
|
|||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
|
|
||||||
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -25,6 +26,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
[TextAction.Underline]: 'Underline',
|
[TextAction.Underline]: 'Underline',
|
||||||
[TextAction.Strikethrough]: 'Strike through',
|
[TextAction.Strikethrough]: 'Strike through',
|
||||||
[TextAction.Code]: 'Mark as Code',
|
[TextAction.Code]: 'Mark as Code',
|
||||||
|
[TextAction.Link]: 'Add Link',
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@ -49,6 +51,26 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
[controller, dispatch, isActive]
|
[controller, dispatch, isActive]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addLink = useCallback(() => {
|
||||||
|
dispatch(newLinkThunk());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const formatClick = useCallback(
|
||||||
|
(format: TextAction) => {
|
||||||
|
switch (format) {
|
||||||
|
case TextAction.Bold:
|
||||||
|
case TextAction.Italic:
|
||||||
|
case TextAction.Underline:
|
||||||
|
case TextAction.Strikethrough:
|
||||||
|
case TextAction.Code:
|
||||||
|
return toggleFormat(format);
|
||||||
|
case TextAction.Link:
|
||||||
|
return addLink();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addLink, toggleFormat]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const isActive = await isFormatActive();
|
const isActive = await isFormatActive();
|
||||||
@ -58,7 +80,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuTooltip title={formatTooltips[format]}>
|
<MenuTooltip title={formatTooltips[format]}>
|
||||||
<IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}>
|
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
|
||||||
<FormatIcon icon={icon} />
|
<FormatIcon icon={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</MenuTooltip>
|
</MenuTooltip>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
||||||
import { TextAction } from '$app/interfaces/document';
|
import { TextAction } from '$app/interfaces/document';
|
||||||
|
import LinkIcon from '@mui/icons-material/AddLink';
|
||||||
export const iconSize = { width: 18, height: 18 };
|
export const iconSize = { width: 18, height: 18 };
|
||||||
|
|
||||||
export default function FormatIcon({ icon }: { icon: string }) {
|
export default function FormatIcon({ icon }: { icon: string }) {
|
||||||
@ -15,6 +16,18 @@ export default function FormatIcon({ icon }: { icon: string }) {
|
|||||||
return <CodeOutlined sx={iconSize} />;
|
return <CodeOutlined sx={iconSize} />;
|
||||||
case TextAction.Strikethrough:
|
case TextAction.Strikethrough:
|
||||||
return <StrikethroughSOutlined sx={iconSize} />;
|
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'}>Link</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -15,13 +15,14 @@ export function useTextActionMenu() {
|
|||||||
const isSingleLine = useMemo(() => {
|
const isSingleLine = useMemo(() => {
|
||||||
return range.focus?.id === range.anchor?.id;
|
return range.focus?.id === range.anchor?.id;
|
||||||
}, [range]);
|
}, [range]);
|
||||||
const focusId = range.focus?.id;
|
const focusId = range.caret?.id;
|
||||||
|
|
||||||
const { node } = useSubscribeNode(focusId || '');
|
const { node } = useSubscribeNode(focusId || '');
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
|
if (!node) return [];
|
||||||
if (isSingleLine) {
|
if (isSingleLine) {
|
||||||
const config = blockConfig[node?.type];
|
const config = blockConfig[node.type];
|
||||||
const { customItems, excludeItems } = {
|
const { customItems, excludeItems } = {
|
||||||
...defaultTextActionProps,
|
...defaultTextActionProps,
|
||||||
...config.textActionMenuProps,
|
...config.textActionMenuProps,
|
||||||
@ -30,7 +31,7 @@ export function useTextActionMenu() {
|
|||||||
} else {
|
} else {
|
||||||
return multiLineTextActionProps.customItems || [];
|
return multiLineTextActionProps.customItems || [];
|
||||||
}
|
}
|
||||||
}, [isSingleLine, node?.type]);
|
}, [isSingleLine, node]);
|
||||||
|
|
||||||
// the groups have default items, so we need to filter the items if this node has excluded items
|
// the groups have default items, so we need to filter the items if this node has excluded items
|
||||||
const groupItems: TextAction[][] = useMemo(() => {
|
const groupItems: TextAction[][] = useMemo(() => {
|
||||||
|
@ -11,6 +11,7 @@ function TextActionMenuList() {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case TextAction.Turn:
|
case TextAction.Turn:
|
||||||
return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
|
return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
|
||||||
|
case TextAction.Link:
|
||||||
case TextAction.Bold:
|
case TextAction.Bold:
|
||||||
case TextAction.Italic:
|
case TextAction.Italic:
|
||||||
case TextAction.Underline:
|
case TextAction.Underline:
|
||||||
|
@ -13,20 +13,12 @@ interface Props {
|
|||||||
}
|
}
|
||||||
function TextBlock({ node, childIds, placeholder }: Props) {
|
function TextBlock({ node, childIds, placeholder }: Props) {
|
||||||
const { value, onChange } = useChange(node);
|
const { value, onChange } = useChange(node);
|
||||||
const { onSelectionChange, selection, lastSelection } = useSelection(node.id);
|
const selectionProps = useSelection(node.id);
|
||||||
const { onKeyDown } = useKeyDown(node.id);
|
const { onKeyDown } = useKeyDown(node.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Editor
|
<Editor value={value} onChange={onChange} {...selectionProps} onKeyDown={onKeyDown} placeholder={placeholder} />
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
onSelectionChange={onSelectionChange}
|
|
||||||
selection={selection}
|
|
||||||
lastSelection={lastSelection}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
<NodeChildren className='pl-[1.5em]' childIds={childIds} />
|
<NodeChildren className='pl-[1.5em]' childIds={childIds} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -89,7 +89,6 @@ export function useKeyDown(id: string) {
|
|||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
||||||
filteredEvents.forEach((event) => event.handler(e));
|
filteredEvents.forEach((event) => event.handler(e));
|
||||||
},
|
},
|
||||||
|
@ -74,8 +74,10 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
[BlockType.HeadingBlock]: () => {
|
[BlockType.HeadingBlock]: () => {
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
if (!flag) return;
|
if (!flag) return;
|
||||||
|
const level = flag.match(/#/g)?.length;
|
||||||
|
if (!level || level > 3) return;
|
||||||
return {
|
return {
|
||||||
level: flag.match(/#/g)?.length,
|
level,
|
||||||
...getTurnIntoBlockDelta(),
|
...getTurnIntoBlockDelta(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { RangeStatic } from 'quill';
|
import { RangeStatic } from 'quill';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
import {
|
||||||
|
useFocused,
|
||||||
|
useRangeRef,
|
||||||
|
useSubscribeDecorate,
|
||||||
|
} from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
|
import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
|
||||||
|
|
||||||
export function useSelection(id: string) {
|
export function useSelection(id: string) {
|
||||||
const rangeRef = useRangeRef();
|
const rangeRef = useRangeRef();
|
||||||
const { focusCaret, lastSelection } = useFocused(id);
|
const { focusCaret } = useFocused(id);
|
||||||
|
const decorateProps = useSubscribeDecorate(id);
|
||||||
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
|
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@ -21,7 +26,6 @@ export function useSelection(id: string) {
|
|||||||
const onSelectionChange = useCallback(
|
const onSelectionChange = useCallback(
|
||||||
(range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
|
(range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
id,
|
id,
|
||||||
@ -36,20 +40,19 @@ export function useSelection(id: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rangeRef.current && rangeRef.current?.isDragging) return;
|
if (rangeRef.current && rangeRef.current?.isDragging) return;
|
||||||
const caret = focusCaret;
|
if (!focusCaret) {
|
||||||
if (!caret) {
|
setSelection(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection({
|
setSelection({
|
||||||
index: caret.index,
|
index: focusCaret.index,
|
||||||
length: caret.length,
|
length: focusCaret.length,
|
||||||
});
|
});
|
||||||
}, [rangeRef, focusCaret]);
|
}, [rangeRef, focusCaret]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
selection,
|
selection,
|
||||||
lastSelection,
|
...decorateProps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Portal, Snackbar } from '@mui/material';
|
||||||
|
import { TransitionProps } from '@mui/material/transitions';
|
||||||
|
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||||
|
|
||||||
|
function SlideTransition(props: SlideProps) {
|
||||||
|
return <Slide {...props} direction='up' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
|
message?: string;
|
||||||
|
key?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
export function useMessage() {
|
||||||
|
const [state, setState] = useState<MessageProps>();
|
||||||
|
const show = useCallback((message: MessageProps) => {
|
||||||
|
setState(message);
|
||||||
|
}, []);
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
setState(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contentHolder = useMemo(() => {
|
||||||
|
const open = !!state;
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Snackbar
|
||||||
|
autoHideDuration={state?.duration}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
open={open}
|
||||||
|
onClose={hide}
|
||||||
|
TransitionProps={{ onExited: hide }}
|
||||||
|
message={state?.message}
|
||||||
|
key={state?.key}
|
||||||
|
TransitionComponent={SlideTransition}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}, [hide, state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
contentHolder,
|
||||||
|
};
|
||||||
|
}
|
@ -3,10 +3,11 @@ import { CodeEditorProps } from '$app/interfaces/document';
|
|||||||
import { Editable, Slate } from 'slate-react';
|
import { Editable, Slate } from 'slate-react';
|
||||||
import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
|
import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
|
||||||
import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
|
import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
|
||||||
import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
|
import { CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
|
||||||
|
import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
|
||||||
|
|
||||||
function CodeEditor({ language, ...props }: CodeEditorProps) {
|
function CodeEditor({ language, ...props }: CodeEditorProps) {
|
||||||
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({
|
const { editor, onChange, value, ref, ...editableProps } = useEditor({
|
||||||
...props,
|
...props,
|
||||||
isCodeBlock: true,
|
isCodeBlock: true,
|
||||||
});
|
});
|
||||||
@ -15,16 +16,14 @@ function CodeEditor({ language, ...props }: CodeEditorProps) {
|
|||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
<Editable
|
<Editable
|
||||||
|
{...editableProps}
|
||||||
decorate={(entry) => {
|
decorate={(entry) => {
|
||||||
const codeRange = decorateCode(entry, language);
|
const codeRange = decorateCode(entry, language);
|
||||||
const range = decorate?.(entry) || [];
|
const range = editableProps.decorate?.(entry) || [];
|
||||||
return [...range, ...codeRange];
|
return [...range, ...codeRange];
|
||||||
}}
|
}}
|
||||||
renderLeaf={CodeLeaf}
|
renderLeaf={(leafProps) => <TextLeaf editor={editor} {...leafProps} isCodeBlock={true} />}
|
||||||
renderElement={CodeBlockElement}
|
renderElement={CodeBlockElement}
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onDOMBeforeInput={onDOMBeforeInput}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
/>
|
||||||
</Slate>
|
</Slate>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,46 +1,4 @@
|
|||||||
import { RenderLeafProps, RenderElementProps } from 'slate-react';
|
import { RenderElementProps } from 'slate-react';
|
||||||
import { BaseText } from 'slate';
|
|
||||||
|
|
||||||
interface CodeLeafProps extends RenderLeafProps {
|
|
||||||
leaf: BaseText & {
|
|
||||||
bold?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
underline?: boolean;
|
|
||||||
strikethrough?: boolean;
|
|
||||||
prism_token?: string;
|
|
||||||
selection_high_lighted?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodeLeaf = (props: CodeLeafProps) => {
|
|
||||||
const { attributes, children, leaf } = props;
|
|
||||||
|
|
||||||
let newChildren = children;
|
|
||||||
if (leaf.bold) {
|
|
||||||
newChildren = <strong>{children}</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.italic) {
|
|
||||||
newChildren = <em>{newChildren}</em>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.underline) {
|
|
||||||
newChildren = <u>{newChildren}</u>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const className = [
|
|
||||||
'token',
|
|
||||||
leaf.prism_token && leaf.prism_token,
|
|
||||||
leaf.strikethrough && 'line-through',
|
|
||||||
leaf.selection_high_lighted && 'bg-main-secondary',
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span {...attributes} className={className.join(' ')}>
|
|
||||||
{newChildren}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CodeBlockElement = (props: RenderElementProps) => {
|
export const CodeBlockElement = (props: RenderElementProps) => {
|
||||||
return (
|
return (
|
||||||
|
@ -6,19 +6,16 @@ import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
|
|||||||
import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
|
import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
|
||||||
|
|
||||||
function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
|
function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
|
||||||
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props);
|
const { editor, onChange, value, ref, ...editableProps } = useEditor(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={'py-0.5'}>
|
<div ref={ref} className={'py-0.5'}>
|
||||||
<Slate editor={editor} onChange={onChange} value={value}>
|
<Slate editor={editor} onChange={onChange} value={value}>
|
||||||
<Editable
|
<Editable
|
||||||
onKeyDown={onKeyDown}
|
renderLeaf={(leafProps) => <TextLeaf {...leafProps} editor={editor} />}
|
||||||
onDOMBeforeInput={onDOMBeforeInput}
|
|
||||||
decorate={decorate}
|
|
||||||
renderLeaf={TextLeaf}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onBlur={onBlur}
|
|
||||||
renderElement={TextElement}
|
renderElement={TextElement}
|
||||||
|
{...editableProps}
|
||||||
/>
|
/>
|
||||||
</Slate>
|
</Slate>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,35 +1,33 @@
|
|||||||
import { RenderLeafProps } from 'slate-react';
|
import { ReactEditor, RenderLeafProps } from 'slate-react';
|
||||||
import { BaseText } from 'slate';
|
import { BaseText } from 'slate';
|
||||||
import { useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
import TextLink from '../TextLink';
|
||||||
|
import { converToIndexLength } from '$app/utils/document/slate_editor';
|
||||||
|
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
|
||||||
|
|
||||||
|
interface Attributes {
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
code?: string;
|
||||||
|
selection_high_lighted?: boolean;
|
||||||
|
href?: string;
|
||||||
|
prism_token?: string;
|
||||||
|
link_selection_lighted?: boolean;
|
||||||
|
link_placeholder?: string;
|
||||||
|
}
|
||||||
interface TextLeafProps extends RenderLeafProps {
|
interface TextLeafProps extends RenderLeafProps {
|
||||||
leaf: BaseText & {
|
leaf: BaseText & Attributes;
|
||||||
bold?: boolean;
|
isCodeBlock?: boolean;
|
||||||
italic?: boolean;
|
editor: ReactEditor;
|
||||||
underline?: boolean;
|
|
||||||
strikethrough?: boolean;
|
|
||||||
code?: string;
|
|
||||||
selection_high_lighted?: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextLeaf = (props: TextLeafProps) => {
|
const TextLeaf = (props: TextLeafProps) => {
|
||||||
const { attributes, children, leaf } = props;
|
const { attributes, children, leaf, isCodeBlock, editor } = props;
|
||||||
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
let newChildren = children;
|
let newChildren = children;
|
||||||
if (leaf.bold) {
|
|
||||||
newChildren = <strong>{children}</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.italic) {
|
|
||||||
newChildren = <em>{newChildren}</em>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.underline) {
|
|
||||||
newChildren = <u>{newChildren}</u>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaf.code) {
|
if (leaf.code) {
|
||||||
newChildren = (
|
newChildren = (
|
||||||
@ -45,12 +43,46 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSelection = useCallback(
|
||||||
|
(node: Element) => {
|
||||||
|
const slateNode = ReactEditor.toSlateNode(editor, node);
|
||||||
|
const path = ReactEditor.findPath(editor, slateNode);
|
||||||
|
const selection = converToIndexLength(editor, {
|
||||||
|
anchor: { path, offset: 0 },
|
||||||
|
focus: { path, offset: leaf.text.length },
|
||||||
|
});
|
||||||
|
return selection;
|
||||||
|
},
|
||||||
|
[editor, leaf]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leaf.href) {
|
||||||
|
newChildren = (
|
||||||
|
<TextLink getSelection={getSelection} title={leaf.text} href={leaf.href}>
|
||||||
|
{newChildren}
|
||||||
|
</TextLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const className = [
|
const className = [
|
||||||
|
isCodeBlock && 'token',
|
||||||
|
leaf.prism_token && leaf.prism_token,
|
||||||
leaf.strikethrough && 'line-through',
|
leaf.strikethrough && 'line-through',
|
||||||
leaf.selection_high_lighted && 'bg-main-secondary',
|
leaf.selection_high_lighted && 'bg-main-secondary',
|
||||||
|
leaf.link_selection_lighted && 'text-link bg-main-secondary',
|
||||||
leaf.code && 'inline-code',
|
leaf.code && 'inline-code',
|
||||||
|
leaf.bold && 'font-bold',
|
||||||
|
leaf.italic && 'italic',
|
||||||
|
leaf.underline && 'underline',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (leaf.link_placeholder && leaf.text) {
|
||||||
|
newChildren = (
|
||||||
|
<LinkHighLight leaf={leaf} title={leaf.link_placeholder}>
|
||||||
|
{newChildren}
|
||||||
|
</LinkHighLight>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span ref={ref} {...attributes} className={className.join(' ')}>
|
<span ref={ref} {...attributes} className={className.join(' ')}>
|
||||||
{newChildren}
|
{newChildren}
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
indent,
|
indent,
|
||||||
outdent,
|
outdent,
|
||||||
} from '$app/utils/document/slate_editor';
|
} from '$app/utils/document/slate_editor';
|
||||||
import { focusNodeByIndex } from '$app/utils/document/node';
|
import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
@ -20,13 +20,13 @@ export function useEditor({
|
|||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
selection,
|
selection,
|
||||||
value: delta,
|
value: delta,
|
||||||
lastSelection,
|
decorateSelection,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
isCodeBlock,
|
isCodeBlock,
|
||||||
|
linkDecorateSelection,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const editor = useSlateYjs({ delta });
|
const { editor } = useSlateYjs({ delta });
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const newValue = useMemo(() => [], []);
|
const newValue = useMemo(() => [], []);
|
||||||
const onSelectionChangeHandler = useCallback(
|
const onSelectionChangeHandler = useCallback(
|
||||||
(slateSelection: Selection) => {
|
(slateSelection: Selection) => {
|
||||||
@ -42,7 +42,7 @@ export function useEditor({
|
|||||||
onChange?.(convertToDelta(slateValue), oldContents);
|
onChange?.(convertToDelta(slateValue), oldContents);
|
||||||
onSelectionChangeHandler(editor.selection);
|
onSelectionChangeHandler(editor.selection);
|
||||||
},
|
},
|
||||||
[delta, editor.selection, onChange, onSelectionChangeHandler]
|
[delta, editor, onChange, onSelectionChangeHandler]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||||
@ -54,27 +54,50 @@ export function useEditor({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getDecorateRange = useCallback(
|
||||||
|
(
|
||||||
|
path: number[],
|
||||||
|
selection:
|
||||||
|
| {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
value: Record<string, boolean | string | undefined>
|
||||||
|
) => {
|
||||||
|
if (!selection) return null;
|
||||||
|
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
|
||||||
|
if (range && !Range.isCollapsed(range)) {
|
||||||
|
const intersection = Range.intersection(range, Editor.range(editor, path));
|
||||||
|
if (intersection) {
|
||||||
|
return {
|
||||||
|
...intersection,
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
const decorate = useCallback(
|
const decorate = useCallback(
|
||||||
(entry: NodeEntry) => {
|
(entry: NodeEntry) => {
|
||||||
const [node, path] = entry;
|
const [node, path] = entry;
|
||||||
if (!lastSelection) return [];
|
|
||||||
const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children);
|
|
||||||
if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) {
|
|
||||||
const intersection = Range.intersection(slateSelection, Editor.range(editor, path));
|
|
||||||
|
|
||||||
if (!intersection) {
|
const ranges: Range[] = [
|
||||||
return [];
|
getDecorateRange(path, decorateSelection, {
|
||||||
}
|
|
||||||
const range = {
|
|
||||||
selection_high_lighted: true,
|
selection_high_lighted: true,
|
||||||
...intersection,
|
}),
|
||||||
};
|
getDecorateRange(path, linkDecorateSelection?.selection, {
|
||||||
|
link_selection_lighted: true,
|
||||||
|
link_placeholder: linkDecorateSelection?.placeholder,
|
||||||
|
}),
|
||||||
|
].filter((range) => range !== null) as Range[];
|
||||||
|
|
||||||
return [range];
|
return ranges;
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
[editor, lastSelection]
|
[decorateSelection, linkDecorateSelection, getDecorateRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onKeyDownRewrite = useCallback(
|
const onKeyDownRewrite = useCallback(
|
||||||
@ -116,14 +139,53 @@ export function useEditor({
|
|||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This is a hack to fix the bug that the editor decoration is updated cause selection is lost
|
||||||
|
const onMouseDownCapture = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
editor.deselect();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
|
||||||
|
if (!range) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
// double click to select a word
|
||||||
|
// This is a hack to fix the bug that mouse down event deselect the selection
|
||||||
|
const onDoubleClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||||
|
if (!range) return;
|
||||||
|
const node = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
const wordIndices = getWordIndices(node, offset);
|
||||||
|
if (wordIndices.length === 0) return;
|
||||||
|
range.setStart(node, wordIndices[0].startIndex);
|
||||||
|
range.setEnd(node, wordIndices[0].endIndex);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection || !ref.current) return;
|
if (!ref.current) return;
|
||||||
|
const isFocused = ReactEditor.isFocused(editor);
|
||||||
|
if (!selection) {
|
||||||
|
isFocused && editor.deselect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
||||||
if (!slateSelection) return;
|
if (!slateSelection) return;
|
||||||
const isFocused = ReactEditor.isFocused(editor);
|
|
||||||
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
||||||
focusNodeByIndex(ref.current, selection.index, selection.length);
|
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
||||||
Transforms.select(editor, slateSelection);
|
if (!isSuccess) {
|
||||||
|
Transforms.select(editor, slateSelection);
|
||||||
|
}
|
||||||
}, [editor, selection]);
|
}, [editor, selection]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -135,5 +197,7 @@ export function useEditor({
|
|||||||
ref,
|
ref,
|
||||||
onKeyDown: onKeyDownRewrite,
|
onKeyDown: onKeyDownRewrite,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
onMouseDownCapture,
|
||||||
|
onDoubleClick,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { convertToSlateValue } from '$app/utils/document/slate_editor';
|
import { convertToSlateValue } from '$app/utils/document/slate_editor';
|
||||||
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||||
@ -7,14 +7,14 @@ import { withReact } from 'slate-react';
|
|||||||
import { createEditor } from 'slate';
|
import { createEditor } from 'slate';
|
||||||
|
|
||||||
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||||
const yTextRef = useRef<Y.Text>();
|
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
|
||||||
const sharedType = useMemo(() => {
|
const sharedType = useMemo(() => {
|
||||||
const yDoc = new Y.Doc();
|
const yDoc = new Y.Doc();
|
||||||
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
|
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
|
||||||
const value = convertToSlateValue(delta || new Delta());
|
const value = convertToSlateValue(delta || new Delta());
|
||||||
const insertDelta = slateNodesToInsertDelta(value);
|
const insertDelta = slateNodesToInsertDelta(value);
|
||||||
sharedType.applyDelta(insertDelta);
|
sharedType.applyDelta(insertDelta);
|
||||||
yTextRef.current = insertDelta[0].insert as Y.Text;
|
setYText(insertDelta[0].insert as Y.Text);
|
||||||
return sharedType;
|
return sharedType;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@ -25,19 +25,17 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
YjsEditor.connect(editor);
|
YjsEditor.connect(editor);
|
||||||
return () => {
|
return () => {
|
||||||
yTextRef.current = undefined;
|
|
||||||
YjsEditor.disconnect(editor);
|
YjsEditor.disconnect(editor);
|
||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const yText = yTextRef.current;
|
|
||||||
if (!yText) return;
|
if (!yText) return;
|
||||||
const oldContents = new Delta(yText.toDelta());
|
const oldContents = new Delta(yText.toDelta());
|
||||||
const diffDelta = oldContents.diff(delta || new Delta());
|
const diffDelta = oldContents.diff(delta || new Delta());
|
||||||
if (diffDelta.ops.length === 0) return;
|
if (diffDelta.ops.length === 0) return;
|
||||||
yText.applyDelta(diffDelta.ops);
|
yText.applyDelta(diffDelta.ops);
|
||||||
}, [delta, editor]);
|
}, [delta, editor, yText]);
|
||||||
|
|
||||||
return editor;
|
return { editor };
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { createContext, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Node } from '$app/interfaces/document';
|
import { Node } from '$app/interfaces/document';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,3 +35,5 @@ export function useSubscribeNode(id: string) {
|
|||||||
export function getBlock(id: string) {
|
export function getBlock(id: string) {
|
||||||
return store.getState().document.nodes[id];
|
return store.getState().document.nodes[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NodeIdContext = createContext<string>('');
|
||||||
|
@ -2,6 +2,25 @@ import { useAppSelector } from '$app/stores/store';
|
|||||||
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useSubscribeDecorate(id: string) {
|
||||||
|
const decorateSelection = useAppSelector((state) => {
|
||||||
|
return state.documentRange.ranges[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkDecorateSelection = useAppSelector((state) => {
|
||||||
|
const linkPopoverState = state.documentLinkPopover;
|
||||||
|
if (!linkPopoverState.open || linkPopoverState.id !== id) return;
|
||||||
|
return {
|
||||||
|
selection: linkPopoverState.selection,
|
||||||
|
placeholder: linkPopoverState.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorateSelection,
|
||||||
|
linkDecorateSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
export function useFocused(id: string) {
|
export function useFocused(id: string) {
|
||||||
const caretRef = useRef<RangeStatic>();
|
const caretRef = useRef<RangeStatic>();
|
||||||
const focusCaret = useAppSelector((state) => {
|
const focusCaret = useAppSelector((state) => {
|
||||||
@ -13,23 +32,14 @@ export function useFocused(id: string) {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastSelection = useAppSelector((state) => {
|
|
||||||
return state.documentRange.ranges[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
const focused = useMemo(() => {
|
const focused = useMemo(() => {
|
||||||
return focusCaret && focusCaret?.id === id;
|
return focusCaret && focusCaret?.id === id;
|
||||||
}, [focusCaret, id]);
|
}, [focusCaret, id]);
|
||||||
|
|
||||||
const memoizedLastSelection = useMemo(() => {
|
|
||||||
return lastSelection;
|
|
||||||
}, [JSON.stringify(lastSelection)]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focused,
|
focused,
|
||||||
caretRef,
|
caretRef,
|
||||||
focusCaret,
|
focusCaret,
|
||||||
lastSelection: memoizedLastSelection,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function EditLink({
|
||||||
|
autoFocus,
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
autoFocus?: boolean;
|
||||||
|
text: string;
|
||||||
|
value: string;
|
||||||
|
onChange?: (newValue: string) => void;
|
||||||
|
}) {
|
||||||
|
const [val, setVal] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(val);
|
||||||
|
}, [val, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'mb-2 text-sm'}>
|
||||||
|
<div className={'mb-1 text-shade-2'}>{text}</div>
|
||||||
|
<div className={'flex rounded border bg-main-selector p-1 focus-within:border-main-hovered'}>
|
||||||
|
<input
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className={'flex-1 outline-none'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setVal(newValue);
|
||||||
|
}}
|
||||||
|
value={val}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditLink;
|
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import BlockPortal from '$app/components/document/BlockPortal';
|
||||||
|
import { getNode } from '$app/utils/document/node';
|
||||||
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
|
import CopyIcon from '@mui/icons-material/CopyAll';
|
||||||
|
import { copyText } from '$app/utils/document/copy_paste';
|
||||||
|
import { useMessage } from '$app/components/document/_shared/Message';
|
||||||
|
|
||||||
|
const iconSize = {
|
||||||
|
width: '1rem',
|
||||||
|
height: '1rem',
|
||||||
|
};
|
||||||
|
function EditLinkToolbar({
|
||||||
|
blockId,
|
||||||
|
linkElement,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
href,
|
||||||
|
editing,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
blockId: string;
|
||||||
|
linkElement: HTMLAnchorElement;
|
||||||
|
href: string;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
editing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const { show, contentHolder } = useMessage();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const toolbarDom = ref.current;
|
||||||
|
if (!toolbarDom) return;
|
||||||
|
|
||||||
|
const linkRect = linkElement.getBoundingClientRect();
|
||||||
|
const node = getNode(blockId);
|
||||||
|
if (!node) return;
|
||||||
|
const nodeRect = node.getBoundingClientRect();
|
||||||
|
const top = linkRect.top - nodeRect.top + linkRect.height + 4;
|
||||||
|
const left = linkRect.left - nodeRect.left;
|
||||||
|
toolbarDom.style.top = `${top}px`;
|
||||||
|
toolbarDom.style.left = `${left}px`;
|
||||||
|
toolbarDom.style.opacity = '1';
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{editing && (
|
||||||
|
<BlockPortal blockId={blockId}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
className='absolute z-10 inline-flex h-[32px] min-w-[200px] max-w-[400px] items-stretch overflow-hidden rounded-[8px] bg-white leading-tight text-black shadow-md transition-opacity duration-100'
|
||||||
|
>
|
||||||
|
<div className={'flex w-[100%] items-center justify-between px-2 text-[75%]'}>
|
||||||
|
<div className={'mr-2'}>
|
||||||
|
<LanguageIcon sx={iconSize} />
|
||||||
|
</div>
|
||||||
|
<div className={'mr-2 flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{href}</div>
|
||||||
|
<div
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await copyText(href);
|
||||||
|
show({ message: 'Copied!', duration: 6000 });
|
||||||
|
} catch {
|
||||||
|
show({ message: 'Copy failed!', duration: 6000 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={'mr-2 cursor-pointer'}
|
||||||
|
>
|
||||||
|
<CopyIcon sx={iconSize} />
|
||||||
|
</div>
|
||||||
|
<div onClick={onEdit} className={'cursor-pointer'}>
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BlockPortal>
|
||||||
|
)}
|
||||||
|
{contentHolder}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditLinkToolbar;
|
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { DeleteOutline } from '@mui/icons-material';
|
||||||
|
|
||||||
|
function LinkButton({ icon, title, onClick }: { icon: React.ReactNode; title: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className={'pt-1'}>
|
||||||
|
<Button
|
||||||
|
className={'w-[100%]'}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
startIcon={icon}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkButton;
|
@ -0,0 +1,131 @@
|
|||||||
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import { Divider } from '@mui/material';
|
||||||
|
import { DeleteOutline, Done } from '@mui/icons-material';
|
||||||
|
import EditLink from '$app/components/document/_shared/TextLink/EditLink';
|
||||||
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
|
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
||||||
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
|
import { updateLinkThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
|
import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
|
||||||
|
|
||||||
|
function LinkEditPopover() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const controller = useContext(DocumentControllerContext);
|
||||||
|
const popoverState = useAppSelector((state) => state.documentLinkPopover);
|
||||||
|
const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
dispatch(linkPopoverActions.closeLinkPopover());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const onExited = useCallback(() => {
|
||||||
|
if (!id || !selection) return;
|
||||||
|
const newSelection = {
|
||||||
|
index: selection.index,
|
||||||
|
length: title.length,
|
||||||
|
};
|
||||||
|
dispatch(
|
||||||
|
rangeActions.setRange({
|
||||||
|
id,
|
||||||
|
rangeStatic: newSelection,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
id,
|
||||||
|
...newSelection,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [id, selection, title, dispatch]);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(newVal: { href?: string; title: string }) => {
|
||||||
|
if (!id) return;
|
||||||
|
if (newVal.title === title && newVal.href === href) return;
|
||||||
|
dispatch(
|
||||||
|
updateLinkThunk({
|
||||||
|
id,
|
||||||
|
href: newVal.href,
|
||||||
|
title: newVal.title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, href, id, title]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDone = useCallback(async () => {
|
||||||
|
if (!controller) return;
|
||||||
|
await dispatch(
|
||||||
|
formatLinkThunk({
|
||||||
|
controller,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
}, [controller, dispatch, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
open={open}
|
||||||
|
disableAutoFocus={true}
|
||||||
|
anchorReference='anchorPosition'
|
||||||
|
anchorPosition={anchorPosition}
|
||||||
|
TransitionProps={{
|
||||||
|
onExited,
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex flex-col p-3'>
|
||||||
|
<EditLink
|
||||||
|
text={'URL'}
|
||||||
|
value={href}
|
||||||
|
onChange={(link) => {
|
||||||
|
onChange({
|
||||||
|
href: link,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EditLink
|
||||||
|
text={'Link title'}
|
||||||
|
value={title}
|
||||||
|
onChange={(text) =>
|
||||||
|
onChange({
|
||||||
|
href,
|
||||||
|
title: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<LinkButton
|
||||||
|
title={'Remove link'}
|
||||||
|
icon={<DeleteOutline />}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinkButton title={'Done'} icon={<Done />} onClick={onDone} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkEditPopover;
|
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leaf.text === title || isOverlappingPrefix(leaf.text, title) ? (
|
||||||
|
<span contentEditable={false}>{title}</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkHighLight;
|
||||||
|
|
||||||
|
function isOverlappingPrefix(first: string, second: string): boolean {
|
||||||
|
if (first.length === 0 || second.length === 0) return false;
|
||||||
|
let i = 0;
|
||||||
|
while (i < first.length) {
|
||||||
|
const chars = first.substring(i);
|
||||||
|
if (chars.length > second.length) return false;
|
||||||
|
if (second.startsWith(chars)) return true;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { debounce } from '$app/utils/tool';
|
||||||
|
|
||||||
|
export function useTextLink(id: string) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const ref = useRef<HTMLAnchorElement | null>(null);
|
||||||
|
|
||||||
|
const show = useMemo(() => debounce(() => setEditing(true), 500), []);
|
||||||
|
const hide = useMemo(() => debounce(() => setEditing(false), 500), []);
|
||||||
|
|
||||||
|
const onMouseEnter = useCallback(() => {
|
||||||
|
hide.cancel();
|
||||||
|
show();
|
||||||
|
}, [hide, show]);
|
||||||
|
|
||||||
|
const onMouseLeave = useCallback(() => {
|
||||||
|
show.cancel();
|
||||||
|
hide();
|
||||||
|
}, [hide, show]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
ref,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.hooks';
|
||||||
|
import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { linkPopoverActions } from '$app_reducers/document/slice';
|
||||||
|
|
||||||
|
function TextLink({
|
||||||
|
getSelection,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
getSelection: (node: Element) => {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
} | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const blockId = useContext(NodeIdContext);
|
||||||
|
const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onEdit = useCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const selection = getSelection(ref.current);
|
||||||
|
if (!selection) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
dispatch(
|
||||||
|
linkPopoverActions.setLinkPopover({
|
||||||
|
anchorPosition: {
|
||||||
|
top: rect.top + rect.height,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
},
|
||||||
|
id: blockId,
|
||||||
|
selection,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
open: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [blockId, dispatch, getSelection, href, ref, title]);
|
||||||
|
if (!blockId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
ref={ref}
|
||||||
|
href={href}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='cursor-pointer text-main-hovered'
|
||||||
|
>
|
||||||
|
<span className={' border-b-[1px] border-b-main-hovered '}>{children}</span>
|
||||||
|
</a>
|
||||||
|
{ref.current && (
|
||||||
|
<EditLinkToolbar
|
||||||
|
editing={editing}
|
||||||
|
href={href}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
linkElement={ref.current}
|
||||||
|
blockId={blockId}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextLink;
|
@ -144,6 +144,7 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
export const defaultTextActionProps: TextActionMenuProps = {
|
export const defaultTextActionProps: TextActionMenuProps = {
|
||||||
customItems: [
|
customItems: [
|
||||||
TextAction.Turn,
|
TextAction.Turn,
|
||||||
|
TextAction.Link,
|
||||||
TextAction.Bold,
|
TextAction.Bold,
|
||||||
TextAction.Italic,
|
TextAction.Italic,
|
||||||
TextAction.Underline,
|
TextAction.Underline,
|
||||||
@ -154,29 +155,24 @@ export const defaultTextActionProps: TextActionMenuProps = {
|
|||||||
excludeItems: [],
|
excludeItems: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const groupKeys = {
|
||||||
|
comment: [],
|
||||||
|
format: [
|
||||||
|
TextAction.Bold,
|
||||||
|
TextAction.Italic,
|
||||||
|
TextAction.Underline,
|
||||||
|
TextAction.Strikethrough,
|
||||||
|
TextAction.Code,
|
||||||
|
TextAction.Equation,
|
||||||
|
],
|
||||||
|
link: [TextAction.Link],
|
||||||
|
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],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const multiLineTextActionGroups = [
|
export const multiLineTextActionGroups = [groupKeys.format];
|
||||||
[
|
|
||||||
TextAction.Bold,
|
|
||||||
TextAction.Italic,
|
|
||||||
TextAction.Underline,
|
|
||||||
TextAction.Strikethrough,
|
|
||||||
TextAction.Code,
|
|
||||||
TextAction.Equation,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const textActionGroups = [
|
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
|
||||||
[TextAction.Turn],
|
|
||||||
[
|
|
||||||
TextAction.Bold,
|
|
||||||
TextAction.Italic,
|
|
||||||
TextAction.Underline,
|
|
||||||
TextAction.Strikethrough,
|
|
||||||
TextAction.Code,
|
|
||||||
TextAction.Equation,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
@ -184,6 +184,7 @@ export enum TextAction {
|
|||||||
Strikethrough = 'strikethrough',
|
Strikethrough = 'strikethrough',
|
||||||
Code = 'code',
|
Code = 'code',
|
||||||
Equation = 'equation',
|
Equation = 'equation',
|
||||||
|
Link = 'href',
|
||||||
}
|
}
|
||||||
export interface TextActionMenuProps {
|
export interface TextActionMenuProps {
|
||||||
/**
|
/**
|
||||||
@ -253,7 +254,14 @@ export interface EditorProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value?: Delta;
|
value?: Delta;
|
||||||
selection?: RangeStaticNoId;
|
selection?: RangeStaticNoId;
|
||||||
lastSelection?: RangeStaticNoId;
|
decorateSelection?: RangeStaticNoId;
|
||||||
|
linkDecorateSelection?: {
|
||||||
|
selection?: {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
|
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
|
||||||
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
|
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
|
||||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
@ -264,3 +272,15 @@ export interface BlockCopyData {
|
|||||||
text: string;
|
text: string;
|
||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkPopoverState {
|
||||||
|
anchorPosition?: { top: number; left: number };
|
||||||
|
id?: string;
|
||||||
|
selection?: {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
open?: boolean;
|
||||||
|
href?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DocumentState, BlockData } from '$app/interfaces/document';
|
import { BlockData, DocumentState } from '$app/interfaces/document';
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import Delta, { Op } from 'quill-delta';
|
import Delta, { Op } from 'quill-delta';
|
||||||
|
@ -12,7 +12,7 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
|
|||||||
const { document, documentRange } = state;
|
const { document, documentRange } = state;
|
||||||
const { ranges } = documentRange;
|
const { ranges } = documentRange;
|
||||||
const match = (delta: Delta, format: TextAction) => {
|
const match = (delta: Delta, format: TextAction) => {
|
||||||
return delta.ops.every((op) => op.attributes?.[format] === true);
|
return delta.ops.every((op) => op.attributes?.[format]);
|
||||||
};
|
};
|
||||||
return Object.entries(ranges).every(([id, range]) => {
|
return Object.entries(ranges).every(([id, range]) => {
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
@ -36,15 +36,16 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
const { payload: active } = await dispatch(getFormatActiveThunk(format));
|
const { payload: active } = await dispatch(getFormatActiveThunk(format));
|
||||||
isActive = !!active;
|
isActive = !!active;
|
||||||
}
|
}
|
||||||
|
const formatValue = isActive ? undefined : true;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const { document } = state;
|
const { document } = state;
|
||||||
const { ranges } = state.documentRange;
|
const { ranges } = state.documentRange;
|
||||||
|
|
||||||
const toggle = (delta: Delta, format: TextAction) => {
|
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
|
||||||
const newOps = delta.ops.map((op) => {
|
const newOps = delta.ops.map((op) => {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
...op.attributes,
|
...op.attributes,
|
||||||
[format]: isActive ? undefined : true,
|
[format]: value,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
insert: op.insert,
|
insert: op.insert,
|
||||||
@ -62,7 +63,7 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
const beforeDelta = delta.slice(0, index);
|
const beforeDelta = delta.slice(0, index);
|
||||||
const afterDelta = delta.slice(index + length);
|
const afterDelta = delta.slice(index + length);
|
||||||
const rangeDelta = delta.slice(index, index + length);
|
const rangeDelta = delta.slice(index, index + length);
|
||||||
const toggleFormatDelta = toggle(rangeDelta, format);
|
const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
|
||||||
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
|
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
|
||||||
|
|
||||||
return controller.getUpdateAction({
|
return controller.getUpdateAction({
|
||||||
|
@ -2,3 +2,4 @@ export * from './blocks';
|
|||||||
export * from './turn_to';
|
export * from './turn_to';
|
||||||
export * from './keydown';
|
export * from './keydown';
|
||||||
export * from './range';
|
export * from './range';
|
||||||
|
export { updateLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
|
@ -60,6 +60,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
controller,
|
controller,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
dispatch(rangeActions.clearRange());
|
||||||
dispatch(rangeActions.setCaret(caret));
|
dispatch(rangeActions.setCaret(caret));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -99,7 +100,6 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
|
|
||||||
const children = state.document.children[node.children];
|
const children = state.document.children[node.children];
|
||||||
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||||
console.log('needMoveChildren', needMoveChildren);
|
|
||||||
const moveChildrenAction = needMoveChildren
|
const moveChildrenAction = needMoveChildren
|
||||||
? controller.getMoveChildrenAction(
|
? controller.getMoveChildrenAction(
|
||||||
children.map((id) => state.document.nodes[id]),
|
children.map((id) => state.document.nodes[id]),
|
||||||
@ -150,6 +150,7 @@ export const upDownActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch(rangeActions.clearRange());
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(rangeActions.setCaret(newCaret));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -193,6 +194,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch(rangeActions.clearRange());
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(rangeActions.setCaret(newCaret));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -238,6 +240,8 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch(rangeActions.clearRange());
|
||||||
|
|
||||||
dispatch(rangeActions.setCaret(newCaret));
|
dispatch(rangeActions.setCaret(newCaret));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,103 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
|
import Delta from 'quill-delta';
|
||||||
|
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
|
||||||
|
export const formatLinkThunk = createAsyncThunk<
|
||||||
|
boolean,
|
||||||
|
{
|
||||||
|
controller: DocumentController;
|
||||||
|
}
|
||||||
|
>('document/formatLink', async (payload, thunkAPI) => {
|
||||||
|
const { controller } = payload;
|
||||||
|
const { getState } = thunkAPI;
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const linkPopover = state.documentLinkPopover;
|
||||||
|
if (!linkPopover) return false;
|
||||||
|
const { selection, id, href, title = '' } = linkPopover;
|
||||||
|
if (!selection || !id) return false;
|
||||||
|
const document = state.document;
|
||||||
|
const node = document.nodes[id];
|
||||||
|
const nodeDelta = new Delta(node.data?.delta);
|
||||||
|
const index = selection.index || 0;
|
||||||
|
const length = selection.length || 0;
|
||||||
|
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
|
||||||
|
if (href !== undefined && !regex.test(href)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDelta = new Delta().retain(index).delete(length).insert(title, {
|
||||||
|
href,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDelta = nodeDelta.compose(diffDelta);
|
||||||
|
|
||||||
|
const updateAction = controller.getUpdateAction({
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
delta: newDelta.ops,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await controller.applyActions([updateAction]);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateLinkThunk = createAsyncThunk<
|
||||||
|
void,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
href?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
>('document/updateLink', async (payload, thunkAPI) => {
|
||||||
|
const { id, href, title } = payload;
|
||||||
|
const { dispatch } = thunkAPI;
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
linkPopoverActions.updateLinkPopover({
|
||||||
|
id,
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
|
||||||
|
const { getState, dispatch } = thunkAPI;
|
||||||
|
const { documentRange, document } = getState() as RootState;
|
||||||
|
|
||||||
|
const { caret } = documentRange;
|
||||||
|
if (!caret) return;
|
||||||
|
const { index, length, id } = caret;
|
||||||
|
|
||||||
|
const block = document.nodes[id];
|
||||||
|
const delta = new Delta(block.data.delta).slice(index, index + length);
|
||||||
|
const op = delta.ops.find((op) => op.attributes?.href);
|
||||||
|
const href = op?.attributes?.href as string;
|
||||||
|
|
||||||
|
const domSelection = window.getSelection();
|
||||||
|
if (!domSelection) return;
|
||||||
|
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
|
||||||
|
if (!domRange) return;
|
||||||
|
const title = domSelection.toString();
|
||||||
|
const { top, left, height, width } = domRange.getBoundingClientRect();
|
||||||
|
dispatch(rangeActions.clearRange());
|
||||||
|
dispatch(
|
||||||
|
linkPopoverActions.setLinkPopover({
|
||||||
|
anchorPosition: {
|
||||||
|
top: top + height,
|
||||||
|
left: left + width / 2,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
selection: {
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
},
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
open: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
@ -104,8 +104,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
|
|||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange;
|
const rangeState = state.documentRange;
|
||||||
// if no range, just return
|
|
||||||
if (rangeState.caret && rangeState.caret.length === 0) return;
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
// get merge actions
|
// get merge actions
|
||||||
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
|
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
SlashCommandState,
|
SlashCommandState,
|
||||||
RangeState,
|
RangeState,
|
||||||
RangeStatic,
|
RangeStatic,
|
||||||
|
LinkPopoverState,
|
||||||
} from '@/appflowy_app/interfaces/document';
|
} from '@/appflowy_app/interfaces/document';
|
||||||
import { BlockEventPayloadPB } from '@/services/backend';
|
import { BlockEventPayloadPB } from '@/services/backend';
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
@ -29,6 +30,8 @@ const slashCommandInitialState: SlashCommandState = {
|
|||||||
isSlashCommand: false,
|
isSlashCommand: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const linkPopoverState: LinkPopoverState = {};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
name: 'document',
|
name: 'document',
|
||||||
initialState: initialState,
|
initialState: initialState,
|
||||||
@ -158,7 +161,11 @@ export const rangeSlice = createSlice({
|
|||||||
setDragging: (state, action: PayloadAction<boolean>) => {
|
setDragging: (state, action: PayloadAction<boolean>) => {
|
||||||
state.isDragging = action.payload;
|
state.isDragging = action.payload;
|
||||||
},
|
},
|
||||||
setCaret: (state, action: PayloadAction<RangeStatic>) => {
|
setCaret: (state, action: PayloadAction<RangeStatic | null>) => {
|
||||||
|
if (!action.payload) {
|
||||||
|
state.caret = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = action.payload.id;
|
const id = action.payload.id;
|
||||||
state.ranges[id] = {
|
state.ranges[id] = {
|
||||||
index: action.payload.index,
|
index: action.payload.index,
|
||||||
@ -167,10 +174,7 @@ export const rangeSlice = createSlice({
|
|||||||
state.caret = action.payload;
|
state.caret = action.payload;
|
||||||
},
|
},
|
||||||
clearRange: (state, _: PayloadAction) => {
|
clearRange: (state, _: PayloadAction) => {
|
||||||
state.isDragging = false;
|
return rangeInitialState;
|
||||||
state.ranges = {};
|
|
||||||
state.anchor = undefined;
|
|
||||||
state.focus = undefined;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -197,14 +201,46 @@ export const slashCommandSlice = createSlice({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const linkPopoverSlice = createSlice({
|
||||||
|
name: 'documentLinkPopover',
|
||||||
|
initialState: linkPopoverState,
|
||||||
|
reducers: {
|
||||||
|
setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...action.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
|
||||||
|
const { id } = action.payload;
|
||||||
|
if (!state.open || state.id !== id) return;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...action.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
closeLinkPopover: (state, _: PayloadAction) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
open: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resetLinkPopover: (state, _: PayloadAction) => {
|
||||||
|
return linkPopoverState;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const documentReducers = {
|
export const documentReducers = {
|
||||||
[documentSlice.name]: documentSlice.reducer,
|
[documentSlice.name]: documentSlice.reducer,
|
||||||
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
|
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
|
||||||
[rangeSlice.name]: rangeSlice.reducer,
|
[rangeSlice.name]: rangeSlice.reducer,
|
||||||
[slashCommandSlice.name]: slashCommandSlice.reducer,
|
[slashCommandSlice.name]: slashCommandSlice.reducer,
|
||||||
|
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const documentActions = documentSlice.actions;
|
export const documentActions = documentSlice.actions;
|
||||||
export const rectSelectionActions = rectSelectionSlice.actions;
|
export const rectSelectionActions = rectSelectionSlice.actions;
|
||||||
export const rangeActions = rangeSlice.actions;
|
export const rangeActions = rangeSlice.actions;
|
||||||
export const slashCommandActions = slashCommandSlice.actions;
|
export const slashCommandActions = slashCommandSlice.actions;
|
||||||
|
export const linkPopoverActions = linkPopoverSlice.actions;
|
||||||
|
@ -57,7 +57,6 @@ export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentSt
|
|||||||
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
|
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
|
||||||
const { anchor, focus, ranges } = rangeState;
|
const { anchor, focus, ranges } = rangeState;
|
||||||
if (!anchor || !focus) return;
|
if (!anchor || !focus) return;
|
||||||
if (anchor.id === focus.id) return;
|
|
||||||
|
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const startId = isForward ? anchor.id : focus.id;
|
const startId = isForward ? anchor.id : focus.id;
|
||||||
|
@ -80,3 +80,7 @@ export function getAppendBlockDeltaAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function copyText(text: string) {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
@ -12,7 +12,7 @@ export function exclude(node: Element) {
|
|||||||
return isPlaceholder;
|
return isPlaceholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFirstTextNode(node: Node): Node | null {
|
export function findFirstTextNode(node: Node): Node | null {
|
||||||
if (isTextNode(node)) {
|
if (isTextNode(node)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@ -45,7 +45,7 @@ export function setCursorAtStartOfNode(node: Node): void {
|
|||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastTextNode(node: Node): Node | null {
|
export function findLastTextNode(node: Node): Node | null {
|
||||||
if (isTextNode(node)) {
|
if (isTextNode(node)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@ -174,7 +174,7 @@ export function findTextNode(
|
|||||||
return { remainingIndex };
|
return { remainingIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusNodeByIndex(node: Element, index: number, length: number) {
|
export function getRangeByIndex(node: Element, index: number, length: number) {
|
||||||
const textBoxNode = node.querySelector(`[role="textbox"]`);
|
const textBoxNode = node.querySelector(`[role="textbox"]`);
|
||||||
if (!textBoxNode) return;
|
if (!textBoxNode) return;
|
||||||
const anchorNode = findTextNode(textBoxNode, index);
|
const anchorNode = findTextNode(textBoxNode, index);
|
||||||
@ -185,10 +185,16 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
|
|||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(anchorNode.node, anchorNode.offset || 0);
|
range.setStart(anchorNode.node, anchorNode.offset || 0);
|
||||||
range.setEnd(focusNode.node, focusNode.offset || 0);
|
range.setEnd(focusNode.node, focusNode.offset || 0);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusNodeByIndex(node: Element, index: number, length: number) {
|
||||||
|
const range = getRangeByIndex(node, index, length);
|
||||||
|
if (!range) return false;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeTextBoxByBlockId(blockId: string) {
|
export function getNodeTextBoxByBlockId(blockId: string) {
|
||||||
@ -229,3 +235,31 @@ export function findParent(node: Element, parentSelector: string) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWordIndices(startContainer: Node, startOffset: number) {
|
||||||
|
const textNode = startContainer;
|
||||||
|
const textContent = textNode.textContent || '';
|
||||||
|
|
||||||
|
const wordRegex = /\b\w+\b/g;
|
||||||
|
let match;
|
||||||
|
const wordIndices = [];
|
||||||
|
|
||||||
|
while ((match = wordRegex.exec(textContent)) !== null) {
|
||||||
|
const word = match[0];
|
||||||
|
const wordIndex = match.index;
|
||||||
|
const wordEndIndex = wordIndex + word.length;
|
||||||
|
|
||||||
|
// If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
|
||||||
|
if (startOffset > wordIndex && startOffset <= wordEndIndex) {
|
||||||
|
wordIndices.push({
|
||||||
|
word: word,
|
||||||
|
startIndex: wordIndex,
|
||||||
|
endIndex: wordEndIndex,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are no matches, then the startOffset is greater than the last wordEndIndex
|
||||||
|
return wordIndices;
|
||||||
|
}
|
||||||
|
@ -9,7 +9,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
|
|||||||
|
|
||||||
const nodeRect = node.getBoundingClientRect();
|
const nodeRect = node.getBoundingClientRect();
|
||||||
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
|
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
|
const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
|
||||||
let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
|
let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
|
|||||||
left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
|
left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top,
|
top,
|
||||||
left,
|
left,
|
||||||
|
@ -20,8 +20,8 @@ body {
|
|||||||
@apply bg-[#E0F8FF]
|
@apply bg-[#E0F8FF]
|
||||||
}
|
}
|
||||||
|
|
||||||
#appflowy-block-doc ::selection {
|
div[role="textbox"] ::selection {
|
||||||
@apply bg-[transparent]
|
@apply bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
Loading…
Reference in New Issue
Block a user