feat(core): outline viewer (quick toc) (#7614)
Close: [BS-949](https://linear.app/affine-design/issue/BS-949/outline-viewer-加入到affine) Details are in this PR: https://github.com/toeverything/blocksuite/pull/7704
This commit is contained in:
@@ -33,6 +33,7 @@ export type AppSetting = {
|
|||||||
autoDownloadUpdate: boolean;
|
autoDownloadUpdate: boolean;
|
||||||
enableMultiView: boolean;
|
enableMultiView: boolean;
|
||||||
enableTelemetry: boolean;
|
enableTelemetry: boolean;
|
||||||
|
enableOutlineViewer: boolean;
|
||||||
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
|
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
|
||||||
};
|
};
|
||||||
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
|
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
|
||||||
@@ -74,6 +75,7 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
|
|||||||
autoDownloadUpdate: true,
|
autoDownloadUpdate: true,
|
||||||
enableTelemetry: true,
|
enableTelemetry: true,
|
||||||
enableMultiView: false,
|
enableMultiView: false,
|
||||||
|
enableOutlineViewer: false,
|
||||||
editorFlags: {},
|
editorFlags: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,13 @@ const ExperimentalFeaturesItem = ({
|
|||||||
isMutating,
|
isMutating,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
|
testId,
|
||||||
}: {
|
}: {
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
isMutating?: boolean;
|
isMutating?: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
|
testId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.switchRow}>
|
<div className={styles.switchRow}>
|
||||||
@@ -93,6 +95,7 @@ const ExperimentalFeaturesItem = ({
|
|||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={isMutating ? styles.switchDisabled : ''}
|
className={isMutating ? styles.switchDisabled : ''}
|
||||||
|
data-testid={testId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -121,6 +124,26 @@ const SplitViewSettingRow = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OutlineViewerSettingRow = () => {
|
||||||
|
const { appSettings, updateSettings } = useAppSettingHelper();
|
||||||
|
|
||||||
|
const onToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
updateSettings('enableOutlineViewer', checked);
|
||||||
|
},
|
||||||
|
[updateSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExperimentalFeaturesItem
|
||||||
|
title="Outline Viewer"
|
||||||
|
checked={appSettings.enableOutlineViewer}
|
||||||
|
onChange={onToggle}
|
||||||
|
testId="outline-viewer-switch"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// feature flag -> display name
|
// feature flag -> display name
|
||||||
const blocksuiteFeatureFlags: Partial<Record<keyof BlockSuiteFlags, string>> = {
|
const blocksuiteFeatureFlags: Partial<Record<keyof BlockSuiteFlags, string>> = {
|
||||||
enable_expand_database_block: 'Enable Expand Database Block',
|
enable_expand_database_block: 'Enable Expand Database Block',
|
||||||
@@ -177,6 +200,7 @@ const ExperimentalFeaturesMain = () => {
|
|||||||
>
|
>
|
||||||
<SplitViewSettingRow />
|
<SplitViewSettingRow />
|
||||||
<BlocksuiteFeatureFlagSettings />
|
<BlocksuiteFeatureFlagSettings />
|
||||||
|
<OutlineViewerSettingRow />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ import { performanceRenderLogger } from '../../../shared';
|
|||||||
import { PageNotFound } from '../../404';
|
import { PageNotFound } from '../../404';
|
||||||
import * as styles from './detail-page.css';
|
import * as styles from './detail-page.css';
|
||||||
import { DetailPageHeader } from './detail-page-header';
|
import { DetailPageHeader } from './detail-page-header';
|
||||||
|
import { EditorOutlineViewer } from './outline-viewer';
|
||||||
import { EditorChatPanel } from './tabs/chat';
|
import { EditorChatPanel } from './tabs/chat';
|
||||||
import { EditorFramePanel } from './tabs/frame';
|
import { EditorFramePanel } from './tabs/frame';
|
||||||
import { EditorJournalPanel } from './tabs/journal';
|
import { EditorJournalPanel } from './tabs/journal';
|
||||||
import { EditorOutline } from './tabs/outline';
|
import { EditorOutlinePanel } from './tabs/outline';
|
||||||
|
|
||||||
const DetailPageImpl = memo(function DetailPageImpl() {
|
const DetailPageImpl = memo(function DetailPageImpl() {
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
@@ -206,6 +207,11 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
[jumpToPageBlock, docCollection.id, openPage, jumpToTag, workspace.id]
|
[jumpToPageBlock, docCollection.id, openPage, jumpToTag, workspace.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openOutlinePanel = useCallback(() => {
|
||||||
|
workbench.openSidebar();
|
||||||
|
view.activeSidebarTab('outline');
|
||||||
|
}, [workbench, view]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewHeader>
|
<ViewHeader>
|
||||||
@@ -239,6 +245,12 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
</AffineErrorBoundary>
|
</AffineErrorBoundary>
|
||||||
{isInTrash ? <TrashPageFooter /> : null}
|
{isInTrash ? <TrashPageFooter /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{appSettings.enableOutlineViewer && (
|
||||||
|
<EditorOutlineViewer
|
||||||
|
editor={editor}
|
||||||
|
toggleOutlinePanel={openOutlinePanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ViewBody>
|
</ViewBody>
|
||||||
|
|
||||||
<ViewSidebarTab tabId="chat" icon={<AiIcon />} unmountOnInactive={false}>
|
<ViewSidebarTab tabId="chat" icon={<AiIcon />} unmountOnInactive={false}>
|
||||||
@@ -250,7 +262,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
|
|
||||||
<ViewSidebarTab tabId="outline" icon={<TocIcon />}>
|
<ViewSidebarTab tabId="outline" icon={<TocIcon />}>
|
||||||
<EditorOutline editor={editor} />
|
<EditorOutlinePanel editor={editor} />
|
||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
|
|
||||||
<ViewSidebarTab tabId="frame" icon={<FrameIcon />}>
|
<ViewSidebarTab tabId="frame" icon={<FrameIcon />}>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
position: 'fixed',
|
||||||
|
top: 256,
|
||||||
|
right: 22,
|
||||||
|
maxHeight: 'calc(100% - 16px)',
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
|
import { OutlineViewer } from '@blocksuite/presets';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './outline-viewer.css';
|
||||||
|
|
||||||
|
export const EditorOutlineViewer = ({
|
||||||
|
editor,
|
||||||
|
toggleOutlinePanel,
|
||||||
|
}: {
|
||||||
|
editor: AffineEditorContainer | null;
|
||||||
|
toggleOutlinePanel: () => void;
|
||||||
|
}) => {
|
||||||
|
const outlineViewerRef = useRef<OutlineViewer | null>(null);
|
||||||
|
|
||||||
|
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||||
|
if (container) {
|
||||||
|
if (outlineViewerRef.current === null) {
|
||||||
|
console.error('outline viewer should be initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.append(outlineViewerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outlineViewerRef.current) {
|
||||||
|
outlineViewerRef.current = new OutlineViewer();
|
||||||
|
(outlineViewerRef.current as OutlineViewer).editor = editor;
|
||||||
|
(outlineViewerRef.current as OutlineViewer).toggleOutlinePanel =
|
||||||
|
toggleOutlinePanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={styles.root} ref={onRefChange} />;
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { assertExists } from '@blocksuite/global/utils';
|
|
||||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import { OutlinePanel } from '@blocksuite/presets';
|
import { OutlinePanel } from '@blocksuite/presets';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
@@ -6,7 +5,7 @@ import { useCallback, useRef } from 'react';
|
|||||||
import * as styles from './outline.css';
|
import * as styles from './outline.css';
|
||||||
|
|
||||||
// A wrapper for TOCNotesPanel
|
// A wrapper for TOCNotesPanel
|
||||||
export const EditorOutline = ({
|
export const EditorOutlinePanel = ({
|
||||||
editor,
|
editor,
|
||||||
}: {
|
}: {
|
||||||
editor: AffineEditorContainer | null;
|
editor: AffineEditorContainer | null;
|
||||||
@@ -15,7 +14,10 @@ export const EditorOutline = ({
|
|||||||
|
|
||||||
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||||
if (container) {
|
if (container) {
|
||||||
assertExists(outlinePanelRef.current, 'toc panel should be initialized');
|
if (outlinePanelRef.current === null) {
|
||||||
|
console.error('outline panel should be initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
container.append(outlinePanelRef.current);
|
container.append(outlinePanelRef.current);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
getBlockSuiteEditorTitle,
|
getBlockSuiteEditorTitle,
|
||||||
waitForEditorLoad,
|
waitForEditorLoad,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
|
import {
|
||||||
|
confirmExperimentalPrompt,
|
||||||
|
openExperimentalFeaturesPanel,
|
||||||
|
openSettingModal,
|
||||||
|
} from '@affine-test/kit/utils/setting';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
@@ -73,3 +78,45 @@ test('link page is useable', async ({ page }) => {
|
|||||||
page.locator('.doc-title-container:has-text("page1")')
|
page.locator('.doc-title-container:has-text("page1")')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outline viewer is useable', async ({ page }) => {
|
||||||
|
await openHomePage(page);
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
await openSettingModal(page);
|
||||||
|
await openExperimentalFeaturesPanel(page);
|
||||||
|
const prompt = page.getByTestId('experimental-prompt');
|
||||||
|
await expect(prompt).toBeVisible();
|
||||||
|
await confirmExperimentalPrompt(page);
|
||||||
|
const settings = page.getByTestId('experimental-settings');
|
||||||
|
const enableOutlineViewerSetting = settings.getByTestId(
|
||||||
|
'outline-viewer-switch'
|
||||||
|
);
|
||||||
|
await expect(enableOutlineViewerSetting).toBeVisible();
|
||||||
|
await enableOutlineViewerSetting.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByTestId('modal-close-button').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
|
await title.pressSequentially('Title');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
expect(await title.innerText()).toBe('Title');
|
||||||
|
await page.keyboard.type('# ');
|
||||||
|
await page.keyboard.type('Heading 1');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('## ');
|
||||||
|
await page.keyboard.type('Heading 2');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
const indicators = page.locator('.outline-heading-indicator');
|
||||||
|
await expect(indicators).toHaveCount(2);
|
||||||
|
await expect(indicators.nth(0)).toBeVisible();
|
||||||
|
await expect(indicators.nth(1)).toBeVisible();
|
||||||
|
|
||||||
|
const viewer = page.locator('affine-outline-panel-body');
|
||||||
|
await indicators.first().hover();
|
||||||
|
await expect(viewer).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user