fix(infra): memory leak (#9013)
This commit is contained in:
@@ -48,16 +48,16 @@ export class EventBus {
|
|||||||
this.listeners[event.id].push(listener);
|
this.listeners[event.id].push(listener);
|
||||||
const off = this.parent?.on(event, listener);
|
const off = this.parent?.on(event, listener);
|
||||||
return () => {
|
return () => {
|
||||||
this.off(event, listener);
|
|
||||||
off?.();
|
off?.();
|
||||||
|
this.off(event.id, listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
off<T>(event: FrameworkEvent<T>, listener: (event: T) => void) {
|
private off(eventId: string, listener: (event: any) => void) {
|
||||||
if (!this.listeners[event.id]) {
|
if (!this.listeners[eventId]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.listeners[event.id] = this.listeners[event.id].filter(
|
this.listeners[eventId] = this.listeners[eventId].filter(
|
||||||
l => l !== listener
|
l => l !== listener
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,15 @@ export class EventBus {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const eventId of Object.keys(this.listeners)) {
|
||||||
|
for (const listener of this.listeners[eventId]) {
|
||||||
|
this.parent?.off(eventId, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventHandler {
|
interface EventHandler {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class ComponentCachePool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Symbol.dispose]() {
|
[Symbol.dispose]() {
|
||||||
@@ -318,5 +319,6 @@ export class BasicFrameworkProvider extends FrameworkProvider {
|
|||||||
}
|
}
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.cache.dispose();
|
this.cache.dispose();
|
||||||
|
this.eventBus.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,4 +82,8 @@ export class Workspace extends Entity {
|
|||||||
}),
|
}),
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.docCollection.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class WorkspaceRepositoryService extends Service {
|
|||||||
return {
|
return {
|
||||||
workspace,
|
workspace,
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
workspace.dispose();
|
workspace.scope.dispose();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ export class WorkspaceService extends Service {
|
|||||||
}
|
}
|
||||||
return this._workspace;
|
return this._workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this._workspace?.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider {
|
|||||||
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
||||||
this.workspaceListStore.set('list', [...list, meta]);
|
this.workspaceListStore.set('list', [...list, meta]);
|
||||||
|
|
||||||
|
docCollection.dispose();
|
||||||
|
|
||||||
return { id, flavour: 'local' };
|
return { id, flavour: 'local' };
|
||||||
}
|
}
|
||||||
workspaces$ = LiveData.from<WorkspaceMetadata[]>(
|
workspaces$ = LiveData.from<WorkspaceMetadata[]>(
|
||||||
@@ -94,6 +96,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider {
|
|||||||
|
|
||||||
applyUpdate(bs.doc, data);
|
applyUpdate(bs.doc, data);
|
||||||
|
|
||||||
|
bs.dispose();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: bs.meta.name,
|
name: bs.meta.name,
|
||||||
avatar: bs.meta.avatar,
|
avatar: bs.meta.avatar,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './observe-intersection';
|
||||||
export * from './observe-resize';
|
export * from './observe-resize';
|
||||||
export { startScopedViewTransition } from './view-transition';
|
export { startScopedViewTransition } from './view-transition';
|
||||||
export * from './with-unit';
|
export * from './with-unit';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
type ObserveIntersection = {
|
||||||
|
callback: (entity: IntersectionObserverEntry) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let _intersectionObserver: IntersectionObserver | null = null;
|
||||||
|
const elementsMap = new WeakMap<Element, Array<ObserveIntersection>>();
|
||||||
|
|
||||||
|
// for debugging
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any)._intersectionObserverElementsMap = elementsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal get or initialize the IntersectionObserver instance
|
||||||
|
*/
|
||||||
|
const getIntersectionObserver = () =>
|
||||||
|
(_intersectionObserver ??= new IntersectionObserver(entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const listeners = elementsMap.get(entry.target) ?? [];
|
||||||
|
listeners.forEach(({ callback }) => callback(entry));
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal remove element's specific listener
|
||||||
|
*/
|
||||||
|
const removeListener = (element: Element, listener: ObserveIntersection) => {
|
||||||
|
if (!element) return;
|
||||||
|
const listeners = elementsMap.get(element) ?? [];
|
||||||
|
const observer = getIntersectionObserver();
|
||||||
|
// remove the listener from the element
|
||||||
|
if (listeners.includes(listener)) {
|
||||||
|
elementsMap.set(
|
||||||
|
element,
|
||||||
|
listeners.filter(l => l !== listener)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// if no more listeners, unobserve the element
|
||||||
|
if (elementsMap.get(element)?.length === 0) {
|
||||||
|
observer.unobserve(element);
|
||||||
|
elementsMap.delete(element);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function to observe the intersection of an element use global IntersectionObserver.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* useEffect(() => {
|
||||||
|
* const dispose1 = observeIntersection(elRef1.current, (entry) => {});
|
||||||
|
* const dispose2 = observeIntersection(elRef2.current, (entry) => {});
|
||||||
|
*
|
||||||
|
* return () => {
|
||||||
|
* dispose1();
|
||||||
|
* dispose2();
|
||||||
|
* };
|
||||||
|
* }, [])
|
||||||
|
* ```
|
||||||
|
* @return A function to dispose the observer.
|
||||||
|
*/
|
||||||
|
export const observeIntersection = (
|
||||||
|
element: Element,
|
||||||
|
callback: ObserveIntersection['callback']
|
||||||
|
) => {
|
||||||
|
const observer = getIntersectionObserver();
|
||||||
|
if (!elementsMap.has(element)) {
|
||||||
|
observer.observe(element);
|
||||||
|
}
|
||||||
|
const prevListeners = elementsMap.get(element) ?? [];
|
||||||
|
const listener = { callback, dispose: () => {} };
|
||||||
|
listener.dispose = () => removeListener(element, listener);
|
||||||
|
|
||||||
|
elementsMap.set(element, [...prevListeners, listener]);
|
||||||
|
|
||||||
|
return listener.dispose;
|
||||||
|
};
|
||||||
@@ -194,6 +194,7 @@ export async function markDownToDoc(
|
|||||||
const collection = new DocCollection({
|
const collection = new DocCollection({
|
||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
collection.awarenessStore.awareness.destroy();
|
||||||
collection.meta.initialize();
|
collection.meta.initialize();
|
||||||
const middlewares = [defaultImageProxyMiddleware];
|
const middlewares = [defaultImageProxyMiddleware];
|
||||||
if (additionalMiddlewares) {
|
if (additionalMiddlewares) {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export class AISlidesRenderer extends WithDisposable(LitElement) {
|
|||||||
|
|
||||||
private _doc!: Doc;
|
private _doc!: Doc;
|
||||||
|
|
||||||
|
private _docCollection: DocCollection | null = null;
|
||||||
|
|
||||||
@query('editor-host')
|
@query('editor-host')
|
||||||
private accessor _editorHost!: EditorHost;
|
private accessor _editorHost!: EditorHost;
|
||||||
|
|
||||||
@@ -233,6 +235,12 @@ export class AISlidesRenderer extends WithDisposable(LitElement) {
|
|||||||
|
|
||||||
doc.resetHistory();
|
doc.resetHistory();
|
||||||
this._doc = doc;
|
this._doc = doc;
|
||||||
|
this._docCollection = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
this._docCollection?.dispose();
|
||||||
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import {
|
|||||||
import { DisposableGroup } from '@blocksuite/affine/global/utils';
|
import { DisposableGroup } from '@blocksuite/affine/global/utils';
|
||||||
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
|
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
|
||||||
import type { Doc } from '@blocksuite/affine/store';
|
import type { Doc } from '@blocksuite/affine/store';
|
||||||
import { use } from 'foxact/use';
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { Suspense, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { DefaultOpenProperty } from '../../doc-properties';
|
import type { DefaultOpenProperty } from '../../doc-properties';
|
||||||
import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
|
import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
|
||||||
@@ -31,24 +30,6 @@ export type EditorProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function usePageRoot(page: Doc) {
|
|
||||||
if (!page.root) {
|
|
||||||
use(
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const disposable = page.slots.rootAdded.once(() => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
window.setTimeout(() => {
|
|
||||||
disposable.dispose();
|
|
||||||
reject(new NoPageRootError(page));
|
|
||||||
}, 20 * 1000);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return page.root;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlockSuiteEditorImpl = ({
|
const BlockSuiteEditorImpl = ({
|
||||||
mode,
|
mode,
|
||||||
page,
|
page,
|
||||||
@@ -58,8 +39,6 @@ const BlockSuiteEditorImpl = ({
|
|||||||
onEditorReady,
|
onEditorReady,
|
||||||
defaultOpenProperty,
|
defaultOpenProperty,
|
||||||
}: EditorProps) => {
|
}: EditorProps) => {
|
||||||
usePageRoot(page);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disposable = page.slots.blockUpdated.once(() => {
|
const disposable = page.slots.blockUpdated.once(() => {
|
||||||
page.collection.setDocMeta(page.id, {
|
page.collection.setDocMeta(page.id, {
|
||||||
@@ -142,9 +121,33 @@ const BlockSuiteEditorImpl = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BlockSuiteEditor = (props: EditorProps) => {
|
export const BlockSuiteEditor = (props: EditorProps) => {
|
||||||
return (
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
<Suspense fallback={<EditorLoading />}>
|
const [error, setError] = useState<Error | null>(null);
|
||||||
<BlockSuiteEditorImpl key={props.page.id} {...props} />
|
|
||||||
</Suspense>
|
useEffect(() => {
|
||||||
|
if (props.page.root) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const disposable = props.page.slots.rootAdded.once(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
disposable.dispose();
|
||||||
|
setError(new NoPageRootError(props.page));
|
||||||
|
}, 20 * 1000);
|
||||||
|
return () => {
|
||||||
|
disposable.dispose();
|
||||||
|
};
|
||||||
|
}, [props.page]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<EditorLoading />
|
||||||
|
) : (
|
||||||
|
<BlockSuiteEditorImpl key={props.page.id} {...props} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
import type { DocCollection } from '@blocksuite/affine/store';
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
import { useAtomValue } from 'jotai';
|
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||||
import { type ReactNode, Suspense } from 'react';
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
|
|
||||||
import { useDocCollectionPage } from './use-block-suite-workspace-page';
|
|
||||||
|
|
||||||
interface PagePreviewProps {
|
interface PagePreviewProps {
|
||||||
docCollection: DocCollection;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
emptyFallback?: ReactNode;
|
emptyFallback?: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PagePreviewInner = ({
|
const PagePreviewInner = ({
|
||||||
docCollection: workspace,
|
|
||||||
pageId,
|
pageId,
|
||||||
emptyFallback,
|
emptyFallback,
|
||||||
|
fallback,
|
||||||
}: PagePreviewProps) => {
|
}: PagePreviewProps) => {
|
||||||
const page = useDocCollectionPage(workspace, pageId);
|
const docSummary = useService(DocsSearchService);
|
||||||
const previewAtom = useBlockSuitePagePreview(page);
|
const summary = useLiveData(
|
||||||
const preview = useAtomValue(previewAtom);
|
useMemo(
|
||||||
const res = preview ? preview : null;
|
() => LiveData.from(docSummary.watchDocSummary(pageId), null),
|
||||||
return res || emptyFallback;
|
[docSummary, pageId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res =
|
||||||
|
summary === null ? fallback : summary === '' ? emptyFallback : summary;
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PagePreview = (props: PagePreviewProps) => {
|
export const PagePreview = (props: PagePreviewProps) => {
|
||||||
return (
|
return <PagePreviewInner {...props} />;
|
||||||
<Suspense>
|
|
||||||
<PagePreviewInner {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -317,9 +317,7 @@ function pageMetaToListItemProp(
|
|||||||
pageId: item.id,
|
pageId: item.id,
|
||||||
pageIds,
|
pageIds,
|
||||||
title: <PageTitle id={item.id} />,
|
title: <PageTitle id={item.id} />,
|
||||||
preview: (
|
preview: <PagePreview pageId={item.id} />,
|
||||||
<PagePreview docCollection={props.docCollection} pageId={item.id} />
|
|
||||||
),
|
|
||||||
createDate: new Date(item.createDate),
|
createDate: new Date(item.createDate),
|
||||||
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
|
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
|
||||||
to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined,
|
to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IconButton } from '@affine/component';
|
import { IconButton, observeIntersection, Skeleton } from '@affine/component';
|
||||||
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
||||||
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
|
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
|
||||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
@@ -10,9 +10,16 @@ import {
|
|||||||
} from '@affine/core/modules/workbench';
|
} from '@affine/core/modules/workbench';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
import type { DocMeta } from '@blocksuite/affine/store';
|
||||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { forwardRef, type ReactNode, useMemo } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { DocCardTags } from './tag';
|
import { DocCardTags } from './tag';
|
||||||
@@ -38,11 +45,11 @@ export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
|
|||||||
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
||||||
function DocCard(
|
function DocCard(
|
||||||
{ showTags = true, meta, className, autoHeightById, ...attrs },
|
{ showTags = true, meta, className, autoHeightById, ...attrs },
|
||||||
ref
|
outerRef
|
||||||
) {
|
) {
|
||||||
|
const containerRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
|
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
|
||||||
const workspace = useService(WorkspaceService).workspace;
|
|
||||||
const docDisplayService = useService(DocDisplayMetaService);
|
const docDisplayService = useService(DocDisplayMetaService);
|
||||||
const titleInfo = useLiveData(docDisplayService.title$(meta.id));
|
const titleInfo = useLiveData(docDisplayService.title$(meta.id));
|
||||||
const title =
|
const title =
|
||||||
@@ -64,13 +71,35 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
|||||||
return { height: `${rows * 18}px` };
|
return { height: `${rows * 18}px` };
|
||||||
}, [autoHeightById, meta.id]);
|
}, [autoHeightById, meta.id]);
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const dispose = observeIntersection(containerRef.current, entry => {
|
||||||
|
setVisible(entry.isIntersecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkbenchLink
|
<WorkbenchLink
|
||||||
to={`/${meta.id}`}
|
to={`/${meta.id}`}
|
||||||
ref={ref}
|
ref={ref => {
|
||||||
|
containerRef.current = ref;
|
||||||
|
if (typeof outerRef === 'function') {
|
||||||
|
outerRef(ref);
|
||||||
|
} else if (outerRef) {
|
||||||
|
outerRef.current = ref;
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={clsx(styles.card, className)}
|
className={clsx(styles.card, className)}
|
||||||
data-testid="doc-card"
|
data-testid="doc-card"
|
||||||
data-doc-id={meta.id}
|
data-doc-id={meta.id}
|
||||||
|
data-visible={visible}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
<header className={styles.head} data-testid="doc-card-header">
|
<header className={styles.head} data-testid="doc-card-header">
|
||||||
@@ -83,11 +112,18 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main className={styles.content} style={contentStyle}>
|
<main className={styles.content} style={contentStyle}>
|
||||||
<PagePreview
|
{visible && (
|
||||||
docCollection={workspace.docCollection}
|
<PagePreview
|
||||||
pageId={meta.id}
|
fallback={
|
||||||
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
<>
|
||||||
/>
|
<Skeleton />
|
||||||
|
<Skeleton width={'60%'} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
pageId={meta.id}
|
||||||
|
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
||||||
</WorkbenchLink>
|
</WorkbenchLink>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class DocsIndexer extends Entity {
|
|||||||
/**
|
/**
|
||||||
* increase this number to re-index all docs
|
* increase this number to re-index all docs
|
||||||
*/
|
*/
|
||||||
static INDEXER_VERSION = 10;
|
static INDEXER_VERSION = 11;
|
||||||
|
|
||||||
private readonly jobQueue: JobQueue<IndexerJobPayload> =
|
private readonly jobQueue: JobQueue<IndexerJobPayload> =
|
||||||
new IndexedDBJobQueue<IndexerJobPayload>(
|
new IndexedDBJobQueue<IndexerJobPayload>(
|
||||||
@@ -85,24 +85,26 @@ export class DocsIndexer extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupListener() {
|
setupListener() {
|
||||||
this.workspaceEngine.doc.storage.eventBus.on(event => {
|
this.disposables.push(
|
||||||
if (WorkspaceDBService.isDBDocId(event.docId)) {
|
this.workspaceEngine.doc.storage.eventBus.on(event => {
|
||||||
// skip db doc
|
if (WorkspaceDBService.isDBDocId(event.docId)) {
|
||||||
return;
|
// skip db doc
|
||||||
}
|
return;
|
||||||
if (event.clientId === this.workspaceEngine.doc.clientId) {
|
}
|
||||||
this.jobQueue
|
if (event.clientId === this.workspaceEngine.doc.clientId) {
|
||||||
.enqueue([
|
this.jobQueue
|
||||||
{
|
.enqueue([
|
||||||
batchKey: event.docId,
|
{
|
||||||
payload: { storageDocId: event.docId },
|
batchKey: event.docId,
|
||||||
},
|
payload: { storageDocId: event.docId },
|
||||||
])
|
},
|
||||||
.catch(err => {
|
])
|
||||||
console.error('Error enqueueing job', err);
|
.catch(err => {
|
||||||
});
|
console.error('Error enqueueing job', err);
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) {
|
async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) {
|
||||||
@@ -298,6 +300,8 @@ export class DocsIndexer extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override dispose(): void {
|
override dispose(): void {
|
||||||
|
super.dispose();
|
||||||
this.runner.stop();
|
this.runner.stop();
|
||||||
|
this.worker?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineSchema } from '@toeverything/infra';
|
import { defineSchema } from '@toeverything/infra';
|
||||||
|
|
||||||
export const docIndexSchema = defineSchema({
|
export const docIndexSchema = defineSchema({
|
||||||
|
docId: 'String',
|
||||||
title: 'FullText',
|
title: 'FullText',
|
||||||
// summary of the doc, used for preview
|
// summary of the doc, used for preview
|
||||||
summary: { type: 'String', index: false },
|
summary: { type: 'String', index: false },
|
||||||
|
|||||||
@@ -632,10 +632,31 @@ export class DocsSearchService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDocTitle(docId: string) {
|
watchDocSummary(docId: string) {
|
||||||
const doc = await this.indexer.docIndex.get(docId);
|
return this.indexer.docIndex
|
||||||
const title = doc?.get('title');
|
.search$(
|
||||||
return typeof title === 'string' ? title : title?.[0];
|
{
|
||||||
|
type: 'match',
|
||||||
|
field: 'docId',
|
||||||
|
match: docId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['summary'],
|
||||||
|
pagination: {
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
map(({ nodes }) => {
|
||||||
|
const node = nodes.at(0);
|
||||||
|
return (
|
||||||
|
(typeof node?.fields.summary === 'string'
|
||||||
|
? node?.fields.summary
|
||||||
|
: node?.fields.summary[0]) ?? null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override dispose(): void {
|
override dispose(): void {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ const bookmarkFlavours = new Set([
|
|||||||
'affine:embed-loom',
|
'affine:embed-loom',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const markdownPreviewDocCollection = new DocCollection({
|
||||||
|
id: 'indexer',
|
||||||
|
schema: blocksuiteSchema,
|
||||||
|
});
|
||||||
|
|
||||||
function generateMarkdownPreviewBuilder(
|
function generateMarkdownPreviewBuilder(
|
||||||
yRootDoc: YDoc,
|
yRootDoc: YDoc,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -164,10 +169,7 @@ function generateMarkdownPreviewBuilder(
|
|||||||
|
|
||||||
const markdownAdapter = new MarkdownAdapter(
|
const markdownAdapter = new MarkdownAdapter(
|
||||||
new Job({
|
new Job({
|
||||||
collection: new DocCollection({
|
collection: markdownPreviewDocCollection,
|
||||||
id: 'indexer',
|
|
||||||
schema: blocksuiteSchema,
|
|
||||||
}),
|
|
||||||
middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
|
middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -875,6 +877,7 @@ async function crawlingDocData({
|
|||||||
{
|
{
|
||||||
id: docId,
|
id: docId,
|
||||||
doc: Document.from<DocIndexSchema>(docId, {
|
doc: Document.from<DocIndexSchema>(docId, {
|
||||||
|
docId,
|
||||||
title: docTitle,
|
title: docTitle,
|
||||||
summary,
|
summary,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export async function createWorker(abort: AbortSignal) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
worker.terminate();
|
|
||||||
terminateAbort.abort(MANUALLY_STOP);
|
terminateAbort.abort(MANUALLY_STOP);
|
||||||
|
worker.terminate();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,4 +159,8 @@ export class WorkspacePermission extends Entity {
|
|||||||
permission
|
permission
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.revalidate.unsubscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export class WorkspacePermissionService extends Service {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.permission?.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
async leaveWorkspace() {
|
async leaveWorkspace() {
|
||||||
await this.store.leaveWorkspace(this.workspaceService.workspace.id);
|
await this.store.leaveWorkspace(this.workspaceService.workspace.id);
|
||||||
this.workspacesService.list.revalidate();
|
this.workspacesService.list.revalidate();
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ import { WorkspaceQuota } from '../entities/quota';
|
|||||||
|
|
||||||
export class WorkspaceQuotaService extends Service {
|
export class WorkspaceQuotaService extends Service {
|
||||||
quota = this.framework.createEntity(WorkspaceQuota);
|
quota = this.framework.createEntity(WorkspaceQuota);
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.quota.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,4 +66,8 @@ export class ShareDocsList extends Entity {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.revalidate.unsubscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ export class ShareDocsListService extends Service {
|
|||||||
this.workspaceService.workspace.flavour !== 'local'
|
this.workspaceService.workspace.flavour !== 'local'
|
||||||
? this.framework.createEntity(ShareDocsList)
|
? this.framework.createEntity(ShareDocsList)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.shareDocs?.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs';
|
import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs';
|
||||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
import { encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
import type { Server, ServersService } from '../../cloud';
|
import type { Server, ServersService } from '../../cloud';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +48,7 @@ import { CloudBlobStorage } from './engine/blob-cloud';
|
|||||||
import { StaticBlobStorage } from './engine/blob-static';
|
import { StaticBlobStorage } from './engine/blob-static';
|
||||||
import { CloudDocEngineServer } from './engine/doc-cloud';
|
import { CloudDocEngineServer } from './engine/doc-cloud';
|
||||||
import { CloudStaticDocStorage } from './engine/doc-cloud-static';
|
import { CloudStaticDocStorage } from './engine/doc-cloud-static';
|
||||||
|
import { getWorkspaceProfileWorker } from './out-worker';
|
||||||
|
|
||||||
const getCloudWorkspaceCacheKey = (serverId: string) => {
|
const getCloudWorkspaceCacheKey = (serverId: string) => {
|
||||||
if (serverId === 'affine-cloud') {
|
if (serverId === 'affine-cloud') {
|
||||||
@@ -123,21 +124,25 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// apply initial state
|
try {
|
||||||
await initial(docCollection, blobStorage, docStorage);
|
// apply initial state
|
||||||
|
await initial(docCollection, blobStorage, docStorage);
|
||||||
|
|
||||||
// save workspace to local storage, should be vary fast
|
// save workspace to local storage, should be vary fast
|
||||||
await docStorage.doc.set(
|
await docStorage.doc.set(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
encodeStateAsUpdate(docCollection.doc)
|
encodeStateAsUpdate(docCollection.doc)
|
||||||
);
|
);
|
||||||
for (const subdocs of docCollection.doc.getSubdocs()) {
|
for (const subdocs of docCollection.doc.getSubdocs()) {
|
||||||
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revalidate();
|
||||||
|
await this.waitForLoaded();
|
||||||
|
} finally {
|
||||||
|
docCollection.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.revalidate();
|
|
||||||
await this.waitForLoaded();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
flavour: this.server.id,
|
flavour: this.server.id,
|
||||||
@@ -229,7 +234,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
const docStorage = this.storageProvider.getDocStorage(id);
|
const docStorage = this.storageProvider.getDocStorage(id);
|
||||||
// download root doc
|
// download root doc
|
||||||
const localData = await docStorage.doc.get(id);
|
const localData = await docStorage.doc.get(id);
|
||||||
const cloudData = await cloudStorage.pull(id);
|
const cloudData = (await cloudStorage.pull(id))?.data;
|
||||||
|
|
||||||
const info = await this.getWorkspaceInfo(id, signal);
|
const info = await this.getWorkspaceInfo(id, signal);
|
||||||
|
|
||||||
@@ -241,17 +246,16 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bs = new DocCollection({
|
const client = getWorkspaceProfileWorker();
|
||||||
id,
|
|
||||||
schema: getAFFiNEWorkspaceSchema(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (localData) applyUpdate(bs.doc, localData);
|
const result = await client.call(
|
||||||
if (cloudData) applyUpdate(bs.doc, cloudData.data);
|
'renderWorkspaceProfile',
|
||||||
|
[localData, cloudData].filter(Boolean) as Uint8Array[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: bs.meta.name,
|
name: result.name,
|
||||||
avatar: bs.meta.avatar,
|
avatar: result.avatar,
|
||||||
isOwner: info.isOwner,
|
isOwner: info.isOwner,
|
||||||
isAdmin: info.isAdmin,
|
isAdmin: info.isAdmin,
|
||||||
isTeam: info.workspace.team,
|
isTeam: info.workspace.team,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||||
|
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import type { WorkerOps } from './worker-ops';
|
||||||
|
|
||||||
|
const consumer = new OpConsumer<WorkerOps>(globalThis as MessageCommunicapable);
|
||||||
|
|
||||||
|
consumer.register('renderWorkspaceProfile', data => {
|
||||||
|
const doc = new YDoc({
|
||||||
|
guid: 'workspace',
|
||||||
|
});
|
||||||
|
for (const update of data) {
|
||||||
|
applyUpdate(doc, update);
|
||||||
|
}
|
||||||
|
const meta = doc.getMap('meta');
|
||||||
|
const name = meta.get('name');
|
||||||
|
const avatar = meta.get('avatar');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: typeof name === 'string' ? name : undefined,
|
||||||
|
avatar: typeof avatar === 'string' ? avatar : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
consumer.listen();
|
||||||
@@ -18,12 +18,13 @@ import {
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
import { encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
import { DesktopApiService } from '../../desktop-api';
|
import { DesktopApiService } from '../../desktop-api';
|
||||||
import type { WorkspaceEngineStorageProvider } from '../providers/engine';
|
import type { WorkspaceEngineStorageProvider } from '../providers/engine';
|
||||||
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
|
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
|
||||||
import { StaticBlobStorage } from './engine/blob-static';
|
import { StaticBlobStorage } from './engine/blob-static';
|
||||||
|
import { getWorkspaceProfileWorker } from './out-worker';
|
||||||
|
|
||||||
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
||||||
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
||||||
@@ -97,21 +98,25 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
blobSources: { main: blobStorage },
|
blobSources: { main: blobStorage },
|
||||||
});
|
});
|
||||||
|
|
||||||
// apply initial state
|
try {
|
||||||
await initial(docCollection, blobStorage, docStorage);
|
// apply initial state
|
||||||
|
await initial(docCollection, blobStorage, docStorage);
|
||||||
|
|
||||||
// save workspace to local storage, should be vary fast
|
// save workspace to local storage, should be vary fast
|
||||||
await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
|
await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
|
||||||
for (const subdocs of docCollection.doc.getSubdocs()) {
|
for (const subdocs of docCollection.doc.getSubdocs()) {
|
||||||
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// save workspace id to local storage
|
||||||
|
setLocalWorkspaceIds(ids => [...ids, id]);
|
||||||
|
|
||||||
|
// notify all browser tabs, so they can update their workspace list
|
||||||
|
this.notifyChannel.postMessage(id);
|
||||||
|
} finally {
|
||||||
|
docCollection.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// save workspace id to local storage
|
|
||||||
setLocalWorkspaceIds(ids => [...ids, id]);
|
|
||||||
|
|
||||||
// notify all browser tabs, so they can update their workspace list
|
|
||||||
this.notifyChannel.postMessage(id);
|
|
||||||
|
|
||||||
return { id, flavour: 'local' };
|
return { id, flavour: 'local' };
|
||||||
}
|
}
|
||||||
workspaces$ = LiveData.from(
|
workspaces$ = LiveData.from(
|
||||||
@@ -158,16 +163,16 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bs = new DocCollection({
|
const client = getWorkspaceProfileWorker();
|
||||||
id,
|
|
||||||
schema: getAFFiNEWorkspaceSchema(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (localData) applyUpdate(bs.doc, localData);
|
const result = await client.call(
|
||||||
|
'renderWorkspaceProfile',
|
||||||
|
[localData].filter(Boolean) as Uint8Array[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: bs.meta.name,
|
name: result.name,
|
||||||
avatar: bs.meta.avatar,
|
avatar: result.avatar,
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { OpClient } from '@toeverything/infra/op';
|
||||||
|
|
||||||
|
import type { WorkerOps } from './worker-ops';
|
||||||
|
|
||||||
|
let worker: OpClient<WorkerOps> | undefined;
|
||||||
|
|
||||||
|
export function getWorkspaceProfileWorker() {
|
||||||
|
if (worker) {
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWorker = new Worker(
|
||||||
|
new URL(
|
||||||
|
/* webpackChunkName: "workspace-profile-worker" */ './in-worker.ts',
|
||||||
|
import.meta.url
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
worker = new OpClient<WorkerOps>(rawWorker);
|
||||||
|
worker.listen();
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { OpSchema } from '@toeverything/infra/op';
|
||||||
|
|
||||||
|
export interface WorkerOps extends OpSchema {
|
||||||
|
renderWorkspaceProfile: [Uint8Array[], { name?: string; avatar?: string }];
|
||||||
|
}
|
||||||
@@ -32,10 +32,18 @@ test('should create a page with a local first avatar and remove it', async ({
|
|||||||
.setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'blue.png'));
|
.setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'blue.png'));
|
||||||
await page.mouse.click(0, 0);
|
await page.mouse.click(0, 0);
|
||||||
await page.getByTestId('workspace-name').click();
|
await page.getByTestId('workspace-name').click();
|
||||||
await page.getByTestId('workspace-card').nth(0).click();
|
await page
|
||||||
|
.getByTestId('workspace-card')
|
||||||
|
.nth(0)
|
||||||
|
.getByTestId('workspace-avatar')
|
||||||
|
.click();
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.getByTestId('workspace-name').click();
|
await page.getByTestId('workspace-name').click();
|
||||||
await page.getByTestId('workspace-card').nth(1).click();
|
await page
|
||||||
|
.getByTestId('workspace-card')
|
||||||
|
.nth(1)
|
||||||
|
.getByTestId('workspace-avatar')
|
||||||
|
.click();
|
||||||
const avatarCanvas = await page
|
const avatarCanvas = await page
|
||||||
.getByTestId('workspace-avatar')
|
.getByTestId('workspace-avatar')
|
||||||
.locator('canvas')
|
.locator('canvas')
|
||||||
|
|||||||
Reference in New Issue
Block a user