mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 03:51:38 +03:00
Merge pull request #368 from toeverything/feat/doublelink220820
Feat/doublelink220820
This commit is contained in:
commit
f3f8fd78de
@ -1,7 +1,5 @@
|
||||
import { Route, Routes, useParams } from 'react-router';
|
||||
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
|
||||
import { Route, Routes, useParams } from 'react-router';
|
||||
import { WorkspaceRootContainer } from './Container';
|
||||
import { Page } from './docs';
|
||||
import { Edgeless } from './Edgeless';
|
||||
|
@ -19,6 +19,7 @@ declare module 'slate' {
|
||||
}
|
||||
}
|
||||
|
||||
export { default as isUrl } from 'is-url';
|
||||
export { BlockPreview, StyledBlockPreview } from './block-preview';
|
||||
export { default as Button } from './button';
|
||||
export { CollapsibleTitle } from './collapsible-title';
|
||||
|
@ -41,7 +41,12 @@ import { InlineDate, withDate } from './plugins/date';
|
||||
import { DoubleLinkComponent } from './plugins/DoubleLink';
|
||||
import { LinkComponent, LinkModal, withLinks, wrapLink } from './plugins/link';
|
||||
import { InlineRefLink } from './plugins/reflink';
|
||||
import { Contents, isSelectAll, SlateUtils } from './slate-utils';
|
||||
import {
|
||||
Contents,
|
||||
isSelectAll,
|
||||
SelectionStartAndEnd,
|
||||
SlateUtils,
|
||||
} from './slate-utils';
|
||||
import {
|
||||
getCommentsIdsOnTextNode,
|
||||
getExtraPropertiesFromEditorOutmostNode,
|
||||
@ -88,8 +93,12 @@ export interface TextProps {
|
||||
/** Backspace event */
|
||||
handleBackSpace?: ({
|
||||
isCollAndStart,
|
||||
splitContents,
|
||||
selection,
|
||||
}: {
|
||||
isCollAndStart: boolean;
|
||||
splitContents: Contents;
|
||||
selection: SelectionStartAndEnd;
|
||||
}) => boolean | undefined | Promise<boolean | undefined>;
|
||||
/** Whether markdown is supported */
|
||||
supportMarkdown?: boolean;
|
||||
@ -464,7 +473,13 @@ export const Text = forwardRef<ExtendedTextUtils, TextProps>((props, ref) => {
|
||||
if (!isCool) {
|
||||
hideInlineMenu && hideInlineMenu();
|
||||
}
|
||||
preventBindIfNeeded(handleBackSpace)(e, { isCollAndStart });
|
||||
const selection = utils.current.getSelectionStartAndEnd();
|
||||
const splitContents = utils.current.getSplitContentsBySelection();
|
||||
preventBindIfNeeded(handleBackSpace)(e, {
|
||||
isCollAndStart,
|
||||
selection,
|
||||
splitContents,
|
||||
});
|
||||
};
|
||||
|
||||
const onTab = (e: KeyboardEvent) => {
|
||||
|
@ -6,6 +6,7 @@ import { RenderElementProps } from 'slate-react';
|
||||
|
||||
export type DoubleLinkElement = {
|
||||
type: 'link';
|
||||
linkType: 'doubleLink';
|
||||
workspaceId: string;
|
||||
blockId: string;
|
||||
children: Descendant[];
|
||||
@ -25,15 +26,20 @@ export const DoubleLinkComponent = (props: RenderElementProps) => {
|
||||
[doubleLinkElement, navigate]
|
||||
);
|
||||
|
||||
const displayValue = doubleLinkElement.children
|
||||
.map((item: any) => item.text)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<span onClick={handleClickLinkText}>
|
||||
<a {...attributes} style={{ cursor: 'pointer' }}>
|
||||
<PagesIcon
|
||||
style={{ verticalAlign: 'middle', height: '20px' }}
|
||||
/>
|
||||
<span>
|
||||
<PagesIcon style={{ verticalAlign: 'middle', height: '20px' }} />
|
||||
<a
|
||||
{...attributes}
|
||||
style={{ cursor: 'pointer' }}
|
||||
href={`/${doubleLinkElement.workspaceId}/${doubleLinkElement.blockId}`}
|
||||
>
|
||||
<span onClick={handleClickLinkText}>{children}</span>
|
||||
{children}
|
||||
{displayValue}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
|
@ -1,44 +1,42 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
memo,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import isUrl from 'is-url';
|
||||
import style9 from 'style9';
|
||||
import {
|
||||
Editor,
|
||||
Transforms,
|
||||
Element as SlateElement,
|
||||
Descendant,
|
||||
Range as SlateRange,
|
||||
Node,
|
||||
} from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { LinkIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
MuiTooltip as Tooltip,
|
||||
styled,
|
||||
muiTooltipClasses,
|
||||
styled,
|
||||
type MuiTooltipProps,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
getRelativeUrlForInternalPageUrl,
|
||||
isInternalPageUrl,
|
||||
} from '@toeverything/utils';
|
||||
|
||||
import isUrl from 'is-url';
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
memo,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Descendant,
|
||||
Editor,
|
||||
Element as SlateElement,
|
||||
Node,
|
||||
Range as SlateRange,
|
||||
Transforms,
|
||||
} from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import style9 from 'style9';
|
||||
import { getRandomString } from '../utils';
|
||||
import { colors } from '../../colors';
|
||||
import { LinkIcon } from '@toeverything/components/icons';
|
||||
|
||||
export type LinkElement = {
|
||||
type: 'link';
|
||||
url: string;
|
||||
@ -47,13 +45,20 @@ export type LinkElement = {
|
||||
};
|
||||
|
||||
export const withLinks = (editor: ReactEditor) => {
|
||||
const { isInline } = editor;
|
||||
const { isInline, isVoid } = editor;
|
||||
|
||||
editor.isInline = element => {
|
||||
// @ts-ignore
|
||||
return element.type === 'link' ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = element => {
|
||||
// @ts-ignore
|
||||
return element.type === 'link' && element.linkType === 'doubleLink'
|
||||
? true
|
||||
: isVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
import {
|
||||
Descendant,
|
||||
Editor,
|
||||
Element as SlateElement,
|
||||
Location,
|
||||
Node as SlateNode,
|
||||
Path,
|
||||
@ -405,11 +406,11 @@ Editor.before = function (
|
||||
if (element) {
|
||||
if (isInlineAndVoid(editor, element)) {
|
||||
// Inline entities need to be drilled out
|
||||
// target = Editor.before(editor, target);
|
||||
target = {
|
||||
path: [0, path[1] - 1],
|
||||
offset: 0,
|
||||
};
|
||||
target = Editor.before(editor, target);
|
||||
// target = {
|
||||
// path: [0, path[1] - 1],
|
||||
// offset: 0,
|
||||
// };
|
||||
} else if (editor.isInline(element) && !editor.isVoid(element)) {
|
||||
// Inline styles such as hyperlinks need to drill directly into it
|
||||
const inlineTextLength = element?.children?.[0]?.text?.length;
|
||||
@ -503,7 +504,7 @@ Editor.after = function (
|
||||
return target;
|
||||
};
|
||||
|
||||
type SelectionStartAndEnd = {
|
||||
export type SelectionStartAndEnd = {
|
||||
selectionStart: Point;
|
||||
selectionEnd: Point;
|
||||
};
|
||||
@ -517,6 +518,10 @@ export type Contents = {
|
||||
content: Descendant[];
|
||||
isEmpty: boolean;
|
||||
};
|
||||
contentSelection: {
|
||||
content: Descendant[];
|
||||
isEmpty: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
class SlateUtils {
|
||||
@ -572,6 +577,7 @@ class SlateUtils {
|
||||
anchor: point1,
|
||||
focus: point2,
|
||||
});
|
||||
|
||||
if (!fragment.length) {
|
||||
console.error('Debug information:', point1, point2, fragment);
|
||||
throw new Error('Failed to get content between!');
|
||||
@ -602,7 +608,7 @@ class SlateUtils {
|
||||
for (let i = 0; i < fragmentChildren.length; i++) {
|
||||
const child = fragmentChildren[i];
|
||||
if ('type' in child && child.type === 'link') {
|
||||
i !== fragmentChildren.length - 1 && textChildren.push(child);
|
||||
textChildren.push(child);
|
||||
continue;
|
||||
}
|
||||
if (!('text' in child)) {
|
||||
@ -638,6 +644,10 @@ class SlateUtils {
|
||||
content: this.getContentBetween(selectionEnd, end),
|
||||
isEmpty: Point.equals(end, selectionEnd),
|
||||
},
|
||||
contentSelection: {
|
||||
content: this.getContentBetween(selectionStart, selectionEnd),
|
||||
isEmpty: false,
|
||||
},
|
||||
} as Contents;
|
||||
}
|
||||
|
||||
@ -680,10 +690,7 @@ class SlateUtils {
|
||||
}
|
||||
|
||||
public getStart() {
|
||||
return Editor.start(this.editor, {
|
||||
path: [0, 0],
|
||||
offset: 0,
|
||||
});
|
||||
return Editor.start(this.editor, [0]);
|
||||
}
|
||||
|
||||
public getEnd() {
|
||||
@ -713,7 +720,10 @@ class SlateUtils {
|
||||
if (!this.editor) {
|
||||
return undefined;
|
||||
}
|
||||
const { selectionEnd } = this.getSelectionStartAndEnd();
|
||||
const selectionEnd = this.getSelectionStartAndEnd()?.selectionEnd;
|
||||
if (!selectionEnd) {
|
||||
return undefined;
|
||||
}
|
||||
return this.getStringBetween(this.getStart(), selectionEnd);
|
||||
}
|
||||
|
||||
@ -1252,6 +1262,57 @@ class SlateUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public wrapLink(url: string, preSelection?: Location) {
|
||||
if (!ReactEditor.isFocused(this.editor) && preSelection) {
|
||||
Transforms.select(this.editor, preSelection);
|
||||
}
|
||||
if (this.isLinkActive()) {
|
||||
this.unwrapLink();
|
||||
}
|
||||
const { selection } = this.editor;
|
||||
const isCollapsed = selection && this.isCollapsed();
|
||||
const link = {
|
||||
type: 'link',
|
||||
url: url,
|
||||
children: isCollapsed ? [{ text: url }] : [],
|
||||
id: getRandomString('link'),
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(this.editor, link);
|
||||
} else {
|
||||
Transforms.wrapNodes(this.editor, link, { split: true });
|
||||
Transforms.collapse(this.editor, { edge: 'end' });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
ReactEditor.focus(this.editor);
|
||||
});
|
||||
}
|
||||
|
||||
public isLinkActive() {
|
||||
const [link] = Editor.nodes(this.editor, {
|
||||
match: n =>
|
||||
!Editor.isEditor(n) &&
|
||||
SlateElement.isElement(n) &&
|
||||
// @ts-expect-error
|
||||
n.type === 'link',
|
||||
});
|
||||
return !!link;
|
||||
}
|
||||
|
||||
public unwrapLink() {
|
||||
Transforms.unwrapNodes(this.editor, {
|
||||
match: n => {
|
||||
return (
|
||||
!Editor.isEditor(n) &&
|
||||
SlateElement.isElement(n) &&
|
||||
// @ts-expect-error
|
||||
n.type === 'link'
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** todo improve if selection is collapsed */
|
||||
public getCommentsIdsBySelection() {
|
||||
const commentedTextNodes = Editor.nodes(this.editor, {
|
||||
|
@ -108,7 +108,7 @@ const findLowestCommonAncestor = async (
|
||||
|
||||
export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
|
||||
(props, ref) => {
|
||||
const { block, editor, ...otherOptions } = props;
|
||||
const { block, editor, handleEnter, ...otherOptions } = props;
|
||||
const defaultRef = useRef<ExtendedTextUtils>(null);
|
||||
// Maybe there is a better way
|
||||
const textRef =
|
||||
@ -446,6 +446,38 @@ export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTextEnter: TextProps['handleEnter'] = async props => {
|
||||
const { splitContents } = props;
|
||||
if (splitContents) {
|
||||
const { contentBeforeSelection, contentAfterSelection } =
|
||||
splitContents;
|
||||
// after[after.length - 1];
|
||||
if (!contentBeforeSelection.isEmpty) {
|
||||
const beforeSelection = contentBeforeSelection.content;
|
||||
const lastItem: any =
|
||||
beforeSelection.length > 0
|
||||
? beforeSelection[beforeSelection.length - 1]
|
||||
: null;
|
||||
if (lastItem?.linkType === 'doubleLink') {
|
||||
contentBeforeSelection.content.push({ text: '' });
|
||||
}
|
||||
}
|
||||
if (!contentAfterSelection.isEmpty) {
|
||||
const afterSelection = contentAfterSelection.content;
|
||||
const firstItem: any =
|
||||
afterSelection.length > 0 ? afterSelection[0] : null;
|
||||
if (firstItem?.linkType === 'doubleLink') {
|
||||
contentAfterSelection.content.splice(0, 0, {
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handleEnter && handleEnter(props);
|
||||
};
|
||||
|
||||
if (!properties || !properties.text) {
|
||||
return <></>;
|
||||
}
|
||||
@ -467,6 +499,7 @@ export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
|
||||
handleUndo={onUndo}
|
||||
handleRedo={onRedo}
|
||||
handleEsc={onKeyboardEsc}
|
||||
handleEnter={onTextEnter}
|
||||
{...otherOptions}
|
||||
/>
|
||||
);
|
||||
|
@ -5,13 +5,14 @@ import type {
|
||||
} from '@toeverything/components/common';
|
||||
import {
|
||||
BaseRange,
|
||||
Location,
|
||||
Node,
|
||||
Path,
|
||||
Point,
|
||||
Selection as SlateSelection,
|
||||
} from 'slate';
|
||||
import { Editor } from '../editor';
|
||||
import { AsyncBlock } from '../block';
|
||||
import { Editor } from '../editor';
|
||||
import { SelectBlock } from '../selection';
|
||||
|
||||
type TextUtilsFunctions =
|
||||
@ -44,6 +45,7 @@ type TextUtilsFunctions =
|
||||
| 'setSelection'
|
||||
| 'insertNodes'
|
||||
| 'getNodeByPath'
|
||||
| 'wrapLink'
|
||||
| 'getNodeByRange'
|
||||
| 'convertLeaf2Html';
|
||||
|
||||
@ -160,7 +162,7 @@ export class BlockHelper {
|
||||
if (properties.text.value.length === 0) {
|
||||
return properties;
|
||||
}
|
||||
let text_value = properties.text.value;
|
||||
const text_value = properties.text.value;
|
||||
|
||||
const {
|
||||
text: { value: originTextValue, ...otherTextProperties },
|
||||
@ -517,4 +519,13 @@ export class BlockHelper {
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public wrapLink(blockId: string, url: string, preSelection?: Location) {
|
||||
const text_utils = this._blockTextUtilsMap[blockId];
|
||||
if (text_utils) {
|
||||
return text_utils.wrapLink(url, preSelection);
|
||||
}
|
||||
console.warn('Could find the block text utils');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,6 @@ import type {
|
||||
BlockFlavors,
|
||||
ReturnEditorBlock,
|
||||
} from '@toeverything/datasource/db-service';
|
||||
import type { IdList, SelectionInfo, SelectionManager } from './selection';
|
||||
|
||||
import { Point } from '@toeverything/utils';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { AsyncBlock } from './block';
|
||||
@ -23,6 +21,7 @@ import type { BlockCommands } from './commands/block-commands';
|
||||
import type { DragDropManager } from './drag-drop';
|
||||
import { MouseManager } from './mouse';
|
||||
import { ScrollManager } from './scroll';
|
||||
import type { IdList, SelectionInfo, SelectionManager } from './selection';
|
||||
|
||||
// import { BrowserClipboard } from './clipboard/browser-clipboard';
|
||||
|
||||
@ -114,6 +113,7 @@ export interface Virgo {
|
||||
getGroupBlockByPoint: (point: Point) => Promise<AsyncBlock>;
|
||||
isEdgeless: boolean;
|
||||
mouseManager: MouseManager;
|
||||
getHooks: () => HooksRunner & PluginHooks;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
BlockDecoration,
|
||||
MapOperation,
|
||||
} from '@toeverything/datasource/jwt';
|
||||
import type { ComponentType, ReactElement } from 'react';
|
||||
import { ComponentType, ReactElement } from 'react';
|
||||
import type { EventData } from '../block';
|
||||
import { AsyncBlock } from '../block';
|
||||
import { HTML2BlockResult } from '../clipboard';
|
||||
@ -108,7 +108,11 @@ export abstract class BaseView {
|
||||
// Whether the component is empty
|
||||
isEmpty(block: AsyncBlock): boolean {
|
||||
const text = block.getProperty('text');
|
||||
const result = !text?.value?.[0]?.text;
|
||||
// const result = !text?.value?.[0]?.text;
|
||||
const result =
|
||||
text?.value?.findIndex(
|
||||
(item: any) => item.text || item.children?.length
|
||||
) === -1;
|
||||
|
||||
// Assert that the text is really empty
|
||||
if (
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
GroupMenuPlugin,
|
||||
InlineMenuPlugin,
|
||||
LeftMenuPlugin,
|
||||
SelectionGroupPlugin,
|
||||
LinkMenuPlugin,
|
||||
} from './menu';
|
||||
import { FullTextSearchPlugin } from './search';
|
||||
import { TemplatePlugin } from './template';
|
||||
@ -24,4 +24,5 @@ export const plugins: PluginCreator[] = [
|
||||
// SelectionGroupPlugin,
|
||||
AddCommentPlugin,
|
||||
GroupMenuPlugin,
|
||||
LinkMenuPlugin,
|
||||
];
|
||||
|
@ -107,14 +107,14 @@ export const DoubleLinkMenu = ({
|
||||
icon: AddIcon,
|
||||
},
|
||||
});
|
||||
!inAddNewPage &&
|
||||
items.push({
|
||||
content: {
|
||||
id: ADD_NEW_PAGE,
|
||||
content: 'Add new page in...',
|
||||
icon: AddIcon,
|
||||
},
|
||||
});
|
||||
// !inAddNewPage &&
|
||||
// items.push({
|
||||
// content: {
|
||||
// id: ADD_NEW_PAGE,
|
||||
// content: 'Add new page in...',
|
||||
// icon: AddIcon,
|
||||
// },
|
||||
// });
|
||||
return items;
|
||||
}, [searchResultBlocks, inAddNewPage]);
|
||||
|
||||
@ -137,6 +137,7 @@ export const DoubleLinkMenu = ({
|
||||
|
||||
const resetState = useCallback(
|
||||
(preNodeId: string, nextNodeId: string) => {
|
||||
preNodeId &&
|
||||
editor.blockHelper.removeDoubleLinkSearchSlash(preNodeId);
|
||||
setCurBlockId(nextNodeId);
|
||||
setSearchText('');
|
||||
@ -184,16 +185,19 @@ export const DoubleLinkMenu = ({
|
||||
}
|
||||
}
|
||||
const { type, anchorNode } = editor.selection.currentSelectInfo;
|
||||
if (!anchorNode) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isOpen ||
|
||||
(type === 'Range' &&
|
||||
anchorNode &&
|
||||
anchorNode.id !== curBlockId &&
|
||||
editor.blockHelper.isSelectionCollapsed(anchorNode.id))
|
||||
) {
|
||||
const text = editor.blockHelper.getBlockTextBeforeSelection(
|
||||
const text =
|
||||
editor.blockHelper.getBlockTextBeforeSelection(
|
||||
anchorNode.id
|
||||
);
|
||||
) || '';
|
||||
if (text.endsWith('[[')) {
|
||||
resetState(curBlockId, anchorNode.id);
|
||||
}
|
||||
@ -206,7 +210,7 @@ export const DoubleLinkMenu = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor, isOpen, curBlockId, hideMenu]
|
||||
[editor, isOpen, curBlockId, hideMenu, resetState]
|
||||
);
|
||||
|
||||
const handleKeyup = useCallback(
|
||||
@ -251,6 +255,20 @@ export const DoubleLinkMenu = ({
|
||||
};
|
||||
}, [handleKeyup, handleKeyDown, hooks]);
|
||||
|
||||
useEffect(() => {
|
||||
const showDoubleLink = () => {
|
||||
const { anchorNode } = editor.selection.currentSelectInfo;
|
||||
editor.blockHelper.insertNodes(anchorNode.id, [{ text: '[[' }], {
|
||||
select: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
resetState('', anchorNode.id);
|
||||
}, 0);
|
||||
};
|
||||
editor.plugins.observe('showDoubleLink', showDoubleLink);
|
||||
return () => editor.plugins.unobserve('showDoubleLink', showDoubleLink);
|
||||
}, [editor, resetState]);
|
||||
|
||||
const insertDoubleLink = useCallback(
|
||||
async (pageId: string) => {
|
||||
editor.blockHelper.setSelectDoubleLinkSearchSlash(curBlockId);
|
||||
@ -328,6 +346,7 @@ export const DoubleLinkMenu = ({
|
||||
...doubleLinkMenuStyle,
|
||||
}}
|
||||
>
|
||||
{isOpen && (
|
||||
<MuiClickAwayListener onClickAway={() => hideMenu()}>
|
||||
<Popper
|
||||
open={isOpen}
|
||||
@ -368,6 +387,7 @@ export const DoubleLinkMenu = ({
|
||||
)}
|
||||
</Popper>
|
||||
</MuiClickAwayListener>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { BasePlugin } from '../../base-plugin';
|
||||
import { PluginRenderRoot } from '../../utils';
|
||||
import { DoubleLinkMenu } from './DoubleLinkMenu';
|
||||
|
||||
const PLUGIN_NAME = 'reference-menu';
|
||||
const PLUGIN_NAME = 'doublelink-menu';
|
||||
|
||||
export class DoubleLinkMenuPlugin extends BasePlugin {
|
||||
private _root?: PluginRenderRoot;
|
||||
|
@ -4,4 +4,5 @@ export { GroupMenuPlugin } from './group-menu';
|
||||
export { InlineMenuPlugin } from './inline-menu';
|
||||
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
|
||||
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
|
||||
export { LinkMenuPlugin } from './link-menu';
|
||||
export { SelectionGroupPlugin } from './selection-group-menu';
|
||||
|
@ -1,14 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import style9 from 'style9';
|
||||
|
||||
import { Tooltip } from '@toeverything/components/ui';
|
||||
import { uaHelper } from '@toeverything/utils';
|
||||
import {
|
||||
inlineMenuNamesKeys,
|
||||
MacInlineMenuShortcuts,
|
||||
WinInlineMenuShortcuts,
|
||||
} from '../config';
|
||||
import React, { useCallback } from 'react';
|
||||
import style9 from 'style9';
|
||||
import { inlineMenuNamesKeys, WinInlineMenuShortcuts } from '../config';
|
||||
import type { IconItemType, WithEditorSelectionType } from '../types';
|
||||
|
||||
type MenuIconItemProps = IconItemType & WithEditorSelectionType;
|
||||
|
||||
export const MenuIconItem = ({
|
||||
@ -27,6 +23,7 @@ export const MenuIconItem = ({
|
||||
editor,
|
||||
type: nameKey,
|
||||
anchorNodeId: selectionInfo?.anchorNode?.id,
|
||||
setShow,
|
||||
});
|
||||
}
|
||||
if ([inlineMenuNamesKeys.comment].includes(nameKey)) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { SvgIconProps } from '@toeverything/components/ui';
|
||||
import type { Virgo, SelectionInfo } from '@toeverything/framework/virgo';
|
||||
import type { SelectionInfo, Virgo } from '@toeverything/framework/virgo';
|
||||
import { inlineMenuNames, INLINE_MENU_UI_TYPES } from './config';
|
||||
|
||||
export type WithEditorSelectionType = {
|
||||
@ -14,10 +14,12 @@ export type ClickItemHandler = ({
|
||||
type,
|
||||
editor,
|
||||
anchorNodeId,
|
||||
setShow,
|
||||
}: {
|
||||
type: InlineMenuNamesType;
|
||||
editor: Virgo;
|
||||
anchorNodeId: string;
|
||||
setShow?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => void;
|
||||
|
||||
export type IconItemType = {
|
||||
|
@ -107,6 +107,7 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
|
||||
editor,
|
||||
anchorNodeId,
|
||||
type,
|
||||
setShow,
|
||||
}) => {
|
||||
switch (type) {
|
||||
case inlineMenuNamesKeys.text:
|
||||
@ -184,6 +185,8 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
|
||||
editor,
|
||||
blockId: anchorNodeId,
|
||||
});
|
||||
// editor.plugins.emit('showAddLink');
|
||||
// setShow(false);
|
||||
break;
|
||||
case inlineMenuNamesKeys.code:
|
||||
toggle_text_format({
|
||||
@ -438,6 +441,10 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
|
||||
blockType: Protocol.Block.Type.file,
|
||||
});
|
||||
break;
|
||||
case inlineMenuNamesKeys.backlinks:
|
||||
editor.plugins.emit('showDoubleLink');
|
||||
setShow(false);
|
||||
break;
|
||||
default: // do nothing
|
||||
}
|
||||
};
|
||||
|
259
libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx
Normal file
259
libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { CommonListItem, isUrl } from '@toeverything/components/common';
|
||||
import { LinkIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
ListButton,
|
||||
MuiClickAwayListener,
|
||||
MuiGrow as Grow,
|
||||
MuiPaper as Paper,
|
||||
MuiPopper as Popper,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import { PluginHooks, Virgo } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { QueryBlocks, QueryResult } from '../../search';
|
||||
import { DoubleLinkMenuContainer } from '../double-link-menu/Container';
|
||||
|
||||
const ADD_NEW_SUB_PAGE = 'AddNewSubPage';
|
||||
const ADD_NEW_PAGE = 'AddNewPage';
|
||||
|
||||
export type LinkMenuProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
};
|
||||
|
||||
type LinkMenuStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const normalizeUrl = (url: string) => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return /^https?/.test(url) ? url : `${location.protocol}//${url}`;
|
||||
};
|
||||
|
||||
export const LinkMenu = ({ editor, hooks }: LinkMenuProps) => {
|
||||
const { page_id: curPageId } = useParams();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const dialogRef = useRef<HTMLDivElement>();
|
||||
const inputEl = useRef<HTMLInputElement>();
|
||||
const [linkMenuStyle, setLinkMenuStyle] = useState<LinkMenuStyle>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
});
|
||||
const url = '';
|
||||
|
||||
const [curBlockId, setCurBlockId] = useState<string>();
|
||||
const [searchText, setSearchText] = useState<string>();
|
||||
const [searchResultBlocks, setSearchResultBlocks] = useState<QueryResult>(
|
||||
[]
|
||||
);
|
||||
|
||||
const menuTypes = useMemo(() => {
|
||||
return Object.values(searchResultBlocks)
|
||||
.map(({ id }) => id)
|
||||
.concat([ADD_NEW_SUB_PAGE, ADD_NEW_PAGE]);
|
||||
}, [searchResultBlocks]);
|
||||
|
||||
const menuItems: CommonListItem[] = useMemo(() => {
|
||||
const items: CommonListItem[] = [];
|
||||
if (searchResultBlocks?.length > 0) {
|
||||
items.push({
|
||||
renderCustom: () => {
|
||||
return <ListButton content={'LINK TO PAGE'} />;
|
||||
},
|
||||
});
|
||||
items.push(
|
||||
...(searchResultBlocks?.map(
|
||||
block =>
|
||||
({
|
||||
block: {
|
||||
...block,
|
||||
content: block.content || 'Untitled',
|
||||
},
|
||||
} as CommonListItem)
|
||||
) || [])
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}, [searchResultBlocks]);
|
||||
|
||||
useEffect(() => {
|
||||
const text = searchText;
|
||||
QueryBlocks(editor, text, result => {
|
||||
result = result.filter(item => item.id !== curPageId);
|
||||
setSearchResultBlocks(result);
|
||||
});
|
||||
}, [editor, searchText, curPageId]);
|
||||
|
||||
const hideMenu = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
editor.blockHelper.removeDoubleLinkSearchSlash(curBlockId);
|
||||
editor.scrollManager.unLock();
|
||||
}, [curBlockId, editor]);
|
||||
|
||||
const resetState = useCallback(
|
||||
(preNodeId: string, nextNodeId: string) => {
|
||||
setCurBlockId(nextNodeId);
|
||||
setSearchText('');
|
||||
setIsOpen(true);
|
||||
editor.scrollManager.lock();
|
||||
const clientRect =
|
||||
editor.selection.currentSelectInfo?.browserSelection
|
||||
?.getRangeAt(0)
|
||||
?.getBoundingClientRect();
|
||||
if (clientRect) {
|
||||
const rectTop = clientRect.top;
|
||||
const { top, left } = editor.container.getBoundingClientRect();
|
||||
setLinkMenuStyle({
|
||||
top: rectTop - top,
|
||||
left: clientRect.left - left,
|
||||
height: clientRect.height,
|
||||
});
|
||||
setAnchorEl(dialogRef.current);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const showLinkMenu = () => {
|
||||
const { anchorNode } = editor.selection.currentSelectInfo;
|
||||
resetState('', anchorNode.id);
|
||||
};
|
||||
editor.plugins.observe('showAddLink', showLinkMenu);
|
||||
return () => editor.plugins.unobserve('showAddLink', showLinkMenu);
|
||||
}, [editor, resetState]);
|
||||
|
||||
const handleSelected = async (id: string) => {};
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
|
||||
await setSearchText(text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addLinkUrlToText = () => {
|
||||
const newUrl = inputEl.current.value;
|
||||
if (newUrl && newUrl !== url && isUrl(normalizeUrl(newUrl))) {
|
||||
editor.blockHelper.wrapLink(curBlockId, newUrl);
|
||||
hideMenu();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
addLinkUrlToText();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
hideMenu();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '10px',
|
||||
...linkMenuStyle,
|
||||
}}
|
||||
>
|
||||
{isOpen && (
|
||||
<MuiClickAwayListener onClickAway={() => hideMenu()}>
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
transition
|
||||
placement="bottom-start"
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: 'left bottom',
|
||||
}}
|
||||
>
|
||||
<Paper>
|
||||
<LinkModalContainer>
|
||||
<LinkModalContainerIcon>
|
||||
<LinkIcon
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
/>
|
||||
</LinkModalContainerIcon>
|
||||
<LinkModalContainerInput
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Paste link url"
|
||||
autoComplete="off"
|
||||
value={searchText}
|
||||
onChange={handleFilterChange}
|
||||
ref={inputEl}
|
||||
/>
|
||||
</LinkModalContainer>
|
||||
|
||||
{menuItems.length > 0 && (
|
||||
<DoubleLinkMenuContainer
|
||||
editor={editor}
|
||||
hooks={hooks}
|
||||
blockId={curBlockId}
|
||||
onSelected={handleSelected}
|
||||
onClose={hideMenu}
|
||||
items={menuItems}
|
||||
types={menuTypes}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</MuiClickAwayListener>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkModalContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
borderRadius: '4px',
|
||||
boxShadow: theme.affine.shadows.shadow1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
zIndex: '1',
|
||||
}));
|
||||
|
||||
const LinkModalContainerIcon = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
width: '16px',
|
||||
margin: '0 16px 0 4px',
|
||||
color: '#4C6275',
|
||||
}));
|
||||
|
||||
const LinkModalContainerInput = styled('input')(({ theme }) => ({
|
||||
flex: '1',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
padding: '8px',
|
||||
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
|
||||
'::-webkit-input-placeholder': {
|
||||
color: '#98acbd',
|
||||
},
|
||||
color: '#4C6275',
|
||||
}));
|
33
libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx
Normal file
33
libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { BasePlugin } from '../../base-plugin';
|
||||
import { PluginRenderRoot } from '../../utils';
|
||||
import { LinkMenu } from './LinkMenu';
|
||||
|
||||
const PLUGIN_NAME = 'reference-menu';
|
||||
|
||||
export class LinkMenuPlugin extends BasePlugin {
|
||||
private _root?: PluginRenderRoot;
|
||||
|
||||
public static override get pluginName(): string {
|
||||
return PLUGIN_NAME;
|
||||
}
|
||||
|
||||
protected override _onRender(): void {
|
||||
this._root = new PluginRenderRoot({
|
||||
name: PLUGIN_NAME,
|
||||
render: this.editor.reactRenderRoot.render,
|
||||
});
|
||||
this._root.mount();
|
||||
|
||||
this._root?.render(
|
||||
<StrictMode>
|
||||
<LinkMenu editor={this.editor} hooks={this.hooks} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
public override dispose() {
|
||||
this._root?.unmount();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { LinkMenuPlugin } from './Plugin';
|
@ -5,7 +5,7 @@ import {
|
||||
styled,
|
||||
TransitionsModal,
|
||||
} from '@toeverything/components/ui';
|
||||
import { BlockEditor, Virgo } from '@toeverything/framework/virgo';
|
||||
import { BlockEditor, PluginHooks, Virgo } from '@toeverything/framework/virgo';
|
||||
import { throttle } from '@toeverything/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
@ -31,7 +31,7 @@ const styles = style9.create({
|
||||
|
||||
export type QueryResult = Awaited<ReturnType<BlockEditor['search']>>;
|
||||
|
||||
const query_blocks = (
|
||||
const queryBlocksExec = (
|
||||
editor: Virgo,
|
||||
search: string,
|
||||
callback: (result: QueryResult) => void
|
||||
@ -39,45 +39,58 @@ const query_blocks = (
|
||||
(editor as BlockEditor).search(search).then(pages => callback(pages));
|
||||
};
|
||||
|
||||
export const QueryBlocks = throttle(query_blocks, 500);
|
||||
export const QueryBlocks = throttle(queryBlocksExec, 500);
|
||||
|
||||
type SearchProps = {
|
||||
onClose: () => void;
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
};
|
||||
|
||||
export const Search = (props: SearchProps) => {
|
||||
const { workspace_id } = useParams();
|
||||
const { workspace_id: workspaceId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, set_open] = useState(true);
|
||||
const [search, set_search] = useState('');
|
||||
const [result, set_result] = useState<QueryResult>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState<string>();
|
||||
const [result, setResult] = useState<QueryResult>([]);
|
||||
|
||||
useEffect(() => {
|
||||
search !== undefined &&
|
||||
QueryBlocks(props.editor, search, result => {
|
||||
set_result(result);
|
||||
setResult(result);
|
||||
});
|
||||
}, [props.editor, search]);
|
||||
|
||||
const handle_navigate = useCallback(
|
||||
(id: string) => navigate(`/${workspace_id}/${id}`),
|
||||
[navigate, workspace_id]
|
||||
const handleNavigate = useCallback(
|
||||
(id: string) => navigate(`/${workspaceId}/${id}`),
|
||||
[navigate, workspaceId]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setOpen(true);
|
||||
setSearch('');
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// const sub = props.hooks.get(HookType.ON_SEARCH).subscribe(handleSearch);
|
||||
|
||||
// return () => {
|
||||
// sub.unsubscribe();
|
||||
// };
|
||||
// }, [props, handleSearch]);
|
||||
|
||||
return (
|
||||
<TransitionsModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
set_open(false);
|
||||
props.onClose();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Box className={styles('wrapper')}>
|
||||
<SearchInput
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => set_search(e.target.value)}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<ResultContainer
|
||||
sx={{ maxHeight: `${result.length * 28 + 32 + 20}px` }}
|
||||
@ -89,10 +102,12 @@ export const Search = (props: SearchProps) => {
|
||||
<BlockPreview
|
||||
className={styles('resultItem')}
|
||||
key={block.id}
|
||||
block={block}
|
||||
block={{
|
||||
...block,
|
||||
content: block.content || 'Untitled',
|
||||
}}
|
||||
onClick={() => {
|
||||
handle_navigate(block.id);
|
||||
props.onClose();
|
||||
handleNavigate(block.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
@ -1,59 +1,40 @@
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
import { HookType } from '@toeverything/framework/virgo';
|
||||
|
||||
import { BasePlugin } from '../base-plugin';
|
||||
import { PluginRenderRoot } from '../utils';
|
||||
import { Search } from './Search';
|
||||
|
||||
export class FullTextSearchPlugin extends BasePlugin {
|
||||
#root?: PluginRenderRoot;
|
||||
private root?: PluginRenderRoot;
|
||||
|
||||
public static override get pluginName(): string {
|
||||
return 'search';
|
||||
}
|
||||
|
||||
public override init(): void {
|
||||
this.sub.add(
|
||||
this.hooks.get(HookType.ON_SEARCH).subscribe(this._handleSearch)
|
||||
);
|
||||
}
|
||||
|
||||
protected override _onRender(): void {
|
||||
this.#root = new PluginRenderRoot({
|
||||
this.root = new PluginRenderRoot({
|
||||
name: FullTextSearchPlugin.pluginName,
|
||||
render: this.editor.reactRenderRoot.render,
|
||||
});
|
||||
this._renderSearch();
|
||||
}
|
||||
|
||||
private unmount() {
|
||||
if (this.#root) {
|
||||
this.editor.setHotKeysScope();
|
||||
this.#root.unmount();
|
||||
// this.#root = undefined;
|
||||
}
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
|
||||
private _handleSearch = () => {
|
||||
this.editor.setHotKeysScope('search');
|
||||
this.render_search();
|
||||
};
|
||||
private render_search() {
|
||||
if (this.#root) {
|
||||
this.#root.mount();
|
||||
this.#root.render(
|
||||
private _renderSearch() {
|
||||
if (this.root) {
|
||||
this.root.mount();
|
||||
this.root.render(
|
||||
<StrictMode>
|
||||
<Search
|
||||
onClose={() => this.unmount()}
|
||||
editor={this.editor}
|
||||
/>
|
||||
<Search editor={this.editor} hooks={this.hooks} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
}
|
||||
public renderSearch() {
|
||||
this.render_search();
|
||||
this._renderSearch();
|
||||
}
|
||||
|
||||
public override dispose() {
|
||||
this.root?.unmount();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
LogoIcon,
|
||||
SearchIcon,
|
||||
@ -8,10 +6,11 @@ import {
|
||||
} from '@toeverything/components/icons';
|
||||
import { IconButton, styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
useCurrentEditors,
|
||||
useLocalTrigger,
|
||||
useShowSettingsSidebar,
|
||||
} from '@toeverything/datasource/state';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditorBoardSwitcher } from './EditorBoardSwitcher';
|
||||
import { fsApiSupported } from './FileSystem';
|
||||
@ -33,6 +32,14 @@ export const LayoutHeader = () => {
|
||||
}
|
||||
}, [isLocalWorkspace, t]);
|
||||
|
||||
const { currentEditors } = useCurrentEditors();
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
for (const key in currentEditors || {}) {
|
||||
currentEditors[key].getHooks().onSearch();
|
||||
}
|
||||
}, [currentEditors]);
|
||||
|
||||
return (
|
||||
<StyledContainerForHeaderRoot>
|
||||
<StyledHeaderRoot>
|
||||
@ -49,8 +56,8 @@ export const LayoutHeader = () => {
|
||||
<IconButton
|
||||
size="large"
|
||||
hoverColor={'transparent'}
|
||||
onClick={handleSearch}
|
||||
disabled={true}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
|
@ -3,7 +3,6 @@ import * as encoding from 'lib0/encoding';
|
||||
import * as awarenessProtocol from 'y-protocols/awareness';
|
||||
import * as syncProtocol from 'y-protocols/sync';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Message } from './handler';
|
||||
import { readMessage } from './processor';
|
||||
import { WebsocketProvider } from './provider';
|
||||
@ -14,7 +13,7 @@ export const registerBroadcastSubscriber = (
|
||||
document: Y.Doc
|
||||
) => {
|
||||
const channel = provider.broadcastChannel;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subscriber = (data: ArrayBuffer, origin: any) => {
|
||||
if (origin !== provider) {
|
||||
const encoder = readMessage(provider, new Uint8Array(data), false);
|
||||
|
Loading…
Reference in New Issue
Block a user