mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat: support bookmark (#2458)
Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export type IconMenuProps = PropsWithChildren<{
|
||||
endIcon?: ReactElement;
|
||||
iconSize?: [number, number];
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}> &
|
||||
HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user