feat: add @affine/bookmark-block plugin (#2618)

This commit is contained in:
Himself65
2023-05-31 17:08:03 +08:00
committed by GitHub
parent 4e1e4e9435
commit 454f1887cf
15 changed files with 202 additions and 49 deletions

View File

@@ -0,0 +1,29 @@
import type { PluginBlockSuiteAdapter } from '@toeverything/plugin-infra/type';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BookMarkUI } from './ui';
export default {
uiDecorator: editor => {
if (
editor.parentElement &&
editor.page.awarenessStore.getFlag('enable_bookmark_operation')
) {
const div = document.createElement('div');
editor.parentElement.appendChild(div);
const root = createRoot(div);
root.render(
<StrictMode>
<BookMarkUI page={editor.page} />
</StrictMode>
);
return () => {
root.unmount();
div.remove();
};
} else {
return () => {};
}
},
} satisfies Partial<PluginBlockSuiteAdapter>;

View File

@@ -0,0 +1,220 @@
import { MenuItem, PureMenu } from '@affine/component';
import { MuiClickAwayListener } from '@affine/component';
import type { SerializedBlock } from '@blocksuite/blocks';
import {
getCurrentBlockRange,
getCurrentNativeRange,
getVirgoByModel,
} from '@blocksuite/blocks/std';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export type BookMarkProps = {
page: Page;
};
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 shouldShowBookmarkMenu = (pastedBlocks: SerializedBlock[]) => {
if (!pastedBlocks.length || pastedBlocks.length > 1) {
return;
}
const [firstBlock] = pastedBlocks;
if (
!firstBlock.text ||
!firstBlock.text.length ||
firstBlock.text.length > 1
) {
return;
}
return !!firstBlock.text[0].attributes?.link;
};
export const BookMarkUI: FC<BookMarkProps> = ({ page }) => {
const [anchor, setAnchor] = useState<Range | null>(null);
const [selectedOption, setSelectedOption] = useState<string>(
menuOptions[0].id
);
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: () => {
setAnchor(null);
},
}),
Escape: () => {
setAnchor(null);
},
}),
[page, selectedOption]
);
const onKeydown = useCallback(
(e: KeyboardEvent) => {
const shortcut = shortcutMap[e.key];
if (shortcut) {
e.stopPropagation();
e.preventDefault();
shortcut(e, page);
} else {
setAnchor(null);
}
},
[page, shortcutMap]
);
useEffect(() => {
const disposer = page.slots.pasted.on(pastedBlocks => {
if (!shouldShowBookmarkMenu(pastedBlocks)) {
return;
}
setTimeout(() => {
setAnchor(getCurrentNativeRange());
}, 100);
});
return () => {
disposer.dispose();
};
}, [onKeydown, page, shortcutMap]);
useEffect(() => {
if (anchor) {
document.addEventListener('keydown', onKeydown, { capture: true });
} else {
// reset status and remove event
setSelectedOption(menuOptions[0].id);
document.removeEventListener('keydown', onKeydown, { capture: true });
}
return () => {
document.removeEventListener('keydown', onKeydown, { capture: true });
};
}, [anchor, onKeydown]);
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={() => {
handleEnter({
page,
selectedOption: id,
callback: () => {
setAnchor(null);
},
});
}}
disableHover={true}
onMouseEnter={() => {
setSelectedOption(id);
}}
>
{label}
</MenuItem>
);
})}
</PureMenu>
</div>
</MuiClickAwayListener>
) : null;
};

View File

@@ -0,0 +1,32 @@
import { definePlugin } from '@toeverything/plugin-infra/manager';
import { ReleaseStage } from '@toeverything/plugin-infra/type';
definePlugin(
{
id: 'com.blocksuite.bookmark-block',
name: {
fallback: 'BlockSuite Bookmark Block',
i18nKey: 'com.blocksuite.bookmark.name',
},
description: {
fallback: 'Bookmark block',
},
publisher: {
name: {
fallback: 'AFFiNE',
},
link: 'https://affine.pro',
},
stage: ReleaseStage.NIGHTLY,
version: '0.0.1',
},
undefined,
{
load: () => import('./blocksuite/index'),
hotModuleReload: onHot =>
import.meta.webpackHot &&
import.meta.webpackHot.accept('./blocksuite', () =>
onHot(import('./blocksuite/index'))
),
}
);