feat: support bookmark (#2458)

Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
Qi
2023-05-26 14:52:36 +08:00
committed by GitHub
parent f4b3830a0e
commit 6d3c273ffd
19 changed files with 1311 additions and 89 deletions

View File

@@ -16,6 +16,14 @@ import {
blockSuiteEditorHeaderStyle,
blockSuiteEditorStyle,
} from './index.css';
import { bookmarkPlugin } from './plugins/bookmark';
export type EditorPlugin = {
flavour: string;
onInit?: (page: Page, editor: Readonly<EditorContainer>) => void;
onLoad?: (page: Page, editor: EditorContainer) => () => void;
render?: (props: { page: Page }) => ReactElement | null;
};
export type EditorProps = {
page: Page;
@@ -42,6 +50,9 @@ const ImagePreviewModal = lazy(() =>
}))
);
// todo(himself65): plugin-infra should support this
const plugins = [bookmarkPlugin];
const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => {
const { onLoad, page, mode, style, onInit } = props;
const JotaiEditorContainer = useAtomValue(
@@ -66,13 +77,23 @@ const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => {
editor.page = page;
if (page.root === null) {
onInit(page, editor);
plugins.forEach(plugin => {
plugin.onInit?.(page, editor);
});
}
}
}, [onInit, editor, props, page]);
}, [editor, page, onInit]);
useEffect(() => {
if (editor.page && onLoad) {
return onLoad(page, editor);
const disposes = [] as ((() => void) | undefined)[];
disposes.push(onLoad?.(page, editor));
disposes.push(...plugins.map(plugin => plugin.onLoad?.(page, editor)));
return () => {
disposes
.filter((dispose): dispose is () => void => !!dispose)
.forEach(dispose => dispose());
};
}
}, [editor, editor.page, page, onLoad]);
@@ -180,6 +201,12 @@ export const BlockSuiteEditor = memo(function BlockSuiteEditor(
)}
</Suspense>
)}
{plugins.map(plugin => {
const Renderer = plugin.render;
return Renderer ? (
<Renderer page={props.page} key={plugin.flavour} />
) : null;
})}
</ErrorBoundary>
);
});

View File

@@ -0,0 +1,210 @@
import { MenuItem, MuiClickAwayListener, PureMenu } from '@affine/component';
import type { EditorPlugin } from '@affine/component/block-suite-editor';
import {
getCurrentBlockRange,
getCurrentNativeRange,
getVirgoByModel,
hasNativeSelection,
} from '@blocksuite/blocks/std';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import { useEffect, useMemo, useRef, useState } from 'react';
const isCursorInLink = (page: Page) => {
if (!hasNativeSelection()) return false;
const blockRange = getCurrentBlockRange(page);
if (
!blockRange ||
blockRange.type !== 'Native' ||
blockRange.startOffset !== blockRange.endOffset
) {
return false;
}
const {
models: [model],
} = blockRange;
const vEditor = getVirgoByModel(model);
const delta = vEditor?.getDeltaByRangeIndex(blockRange.startOffset);
return delta?.attributes?.link;
};
type ShortcutMap = {
[key: string]: (e: KeyboardEvent, page: Page) => void;
};
const menuOptions = [
{
id: 'dismiss',
label: 'Dismiss',
},
{
id: 'bookmark',
label: 'Create bookmark',
},
];
const handleEnter = ({
page,
selectedOption,
callback,
}: {
page: Page;
selectedOption: keyof ShortcutMap;
callback: () => void;
}) => {
if (selectedOption === 'dismiss') {
return callback();
}
const blockRange = getCurrentBlockRange(page) as Exclude<
ReturnType<typeof getCurrentBlockRange>,
null
>;
const vEditor = getVirgoByModel(blockRange.models[0]);
const linkInfo = vEditor!
.getDeltasByVRange({
index: blockRange.startOffset,
length: 0,
})
.find(delta => delta[0]?.attributes?.link);
if (!linkInfo) {
return;
}
const [, { index, length }] = linkInfo;
const link = linkInfo[0]?.attributes?.link as string;
const model = blockRange.models[0];
const parent = page.getParent(model);
assertExists(parent);
const currentBlockIndex = parent.children.indexOf(model);
page.addBlock(
'affine:bookmark',
{ url: link },
parent,
currentBlockIndex + 1
);
vEditor!.deleteText({
index,
length,
});
if (model.isEmpty()) {
page.deleteBlock(model);
}
return callback();
};
const BookMarkMenu: EditorPlugin['render'] = ({ page }) => {
const [anchor, setAnchor] = useState<Range | null>(null);
const [selectedOption, setSelectedOption] = useState<string>('');
const shouldHijack = useRef(false);
const shortcutMap = useMemo<ShortcutMap>(
() => ({
ArrowUp: () => {
const curIndex = menuOptions.findIndex(
({ id }) => id === selectedOption
);
if (menuOptions[curIndex - 1]) {
setSelectedOption(menuOptions[curIndex - 1].id);
} else if (curIndex === -1) {
setSelectedOption(menuOptions[0].id);
} else {
setSelectedOption(menuOptions[menuOptions.length - 1].id);
}
},
ArrowDown: () => {
const curIndex = menuOptions.findIndex(
({ id }) => id === selectedOption
);
if (curIndex !== -1 && menuOptions[curIndex + 1]) {
setSelectedOption(menuOptions[curIndex + 1].id);
} else {
setSelectedOption(menuOptions[0].id);
}
},
Enter: () =>
handleEnter({
page,
selectedOption,
callback: () => {
shouldHijack.current = false;
setAnchor(null);
},
}),
}),
[page, selectedOption]
);
useEffect(() => {
// TODO: textUpdated slot is not working
// const disposer = page.slots.textUpdated.on(() => {
// console.log('text Updated', page);
// });
const disposer = page.slots.historyUpdated.on(() => {
if (!isCursorInLink(page)) {
return;
}
setAnchor(getCurrentNativeRange());
shouldHijack.current = true;
});
return () => {
// disposer.dispose();
disposer.dispose();
};
}, [page, shortcutMap]);
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if (!shouldHijack.current) {
return;
}
const shortcut = shortcutMap[e.key];
if (shortcut) {
e.stopPropagation();
e.preventDefault();
shortcut(e, page);
}
};
document.addEventListener('keydown', keydown, { capture: true });
return () => {
document.removeEventListener('keydown', keydown, { capture: true });
};
}, [page, shortcutMap]);
return anchor ? (
<MuiClickAwayListener
onClickAway={() => {
setAnchor(null);
setSelectedOption('');
}}
>
<div>
<PureMenu open={!!anchor} anchorEl={anchor} placement="bottom-start">
{menuOptions.map(({ id, label }) => {
return (
<MenuItem
key={id}
active={selectedOption === id}
onClick={() => {}}
>
{label}
</MenuItem>
);
})}
</PureMenu>
</div>
</MuiClickAwayListener>
) : null;
};
const Defender: EditorPlugin['render'] = props => {
const flag = props.page.awarenessStore.getFlag('enable_bookmark_operation');
return flag ? <BookMarkMenu {...props} /> : null;
};
export const bookmarkPlugin: EditorPlugin = {
flavour: 'bookmark',
render: Defender,
};

View File

@@ -13,6 +13,7 @@ export type IconMenuProps = PropsWithChildren<{
endIcon?: ReactElement;
iconSize?: [number, number];
disabled?: boolean;
active?: boolean;
}> &
HTMLAttributes<HTMLButtonElement>;

View File

@@ -51,7 +51,8 @@ export const StyledContent = styled('div')(() => {
export const StyledMenuItem = styled('button')<{
isDir?: boolean;
disabled?: boolean;
}>(({ isDir = false, disabled = false }) => {
active?: boolean;
}>(({ isDir = false, disabled = false, active = false }) => {
return {
width: '100%',
borderRadius: '5px',
@@ -82,5 +83,11 @@ export const StyledMenuItem = styled('button')<{
: {
backgroundColor: 'var(--affine-hover-color)',
},
...(active && !disabled
? {
backgroundColor: 'var(--affine-hover-color)',
}
: {}),
};
});