mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
feat: add @affine/bookmark-block plugin (#2618)
This commit is contained in:
29
plugins/bookmark-block/src/blocksuite/index.tsx
Normal file
29
plugins/bookmark-block/src/blocksuite/index.tsx
Normal 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>;
|
||||
220
plugins/bookmark-block/src/blocksuite/ui.tsx
Normal file
220
plugins/bookmark-block/src/blocksuite/ui.tsx
Normal 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;
|
||||
};
|
||||
32
plugins/bookmark-block/src/index.ts
Normal file
32
plugins/bookmark-block/src/index.ts
Normal 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'))
|
||||
),
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user