feat(mobile): mobile toc menu (#9582)
Close [BS-1787](https://linear.app/affine-design/issue/BS-1787/toc-移动端适配)
This commit is contained in:
@@ -34,11 +34,13 @@ import {
|
|||||||
} from './fragments/frame-panel/header/frames-setting-menu.js';
|
} from './fragments/frame-panel/header/frames-setting-menu.js';
|
||||||
import {
|
import {
|
||||||
AFFINE_FRAME_PANEL,
|
AFFINE_FRAME_PANEL,
|
||||||
|
AFFINE_MOBILE_OUTLINE_MENU,
|
||||||
AFFINE_OUTLINE_PANEL,
|
AFFINE_OUTLINE_PANEL,
|
||||||
AFFINE_OUTLINE_VIEWER,
|
AFFINE_OUTLINE_VIEWER,
|
||||||
CommentPanel,
|
CommentPanel,
|
||||||
DocTitle,
|
DocTitle,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
|
MobileOutlineMenu,
|
||||||
OutlinePanel,
|
OutlinePanel,
|
||||||
OutlineViewer,
|
OutlineViewer,
|
||||||
} from './fragments/index.js';
|
} from './fragments/index.js';
|
||||||
@@ -87,6 +89,7 @@ export function effects() {
|
|||||||
customElements.define('edgeless-editor', EdgelessEditor);
|
customElements.define('edgeless-editor', EdgelessEditor);
|
||||||
customElements.define(AFFINE_FRAME_CARD, FrameCard);
|
customElements.define(AFFINE_FRAME_CARD, FrameCard);
|
||||||
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
|
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
|
||||||
|
customElements.define(AFFINE_MOBILE_OUTLINE_MENU, MobileOutlineMenu);
|
||||||
customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle);
|
customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle);
|
||||||
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
|
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
|
||||||
customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody);
|
customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody);
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './mobile-outline-panel.js';
|
||||||
export * from './outline-panel.js';
|
export * from './outline-panel.js';
|
||||||
export * from './outline-viewer.js';
|
export * from './outline-viewer.js';
|
||||||
|
|||||||
190
blocksuite/presets/src/fragments/outline/mobile-outline-panel.ts
Normal file
190
blocksuite/presets/src/fragments/outline/mobile-outline-panel.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||||
|
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
|
||||||
|
import { matchFlavours, NoteDisplayMode } from '@blocksuite/blocks';
|
||||||
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||||
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import { css, html, LitElement, nothing } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import type { AffineEditorContainer } from '../../editors/editor-container.js';
|
||||||
|
import { getHeadingBlocksFromDoc } from './utils/query.js';
|
||||||
|
import {
|
||||||
|
observeActiveHeadingDuringScroll,
|
||||||
|
scrollToBlockWithHighlight,
|
||||||
|
} from './utils/scroll.js';
|
||||||
|
|
||||||
|
export const AFFINE_MOBILE_OUTLINE_MENU = 'affine-mobile-outline-menu';
|
||||||
|
|
||||||
|
@requiredProperties({
|
||||||
|
editor: PropTypes.object,
|
||||||
|
})
|
||||||
|
export class MobileOutlineMenu extends SignalWatcher(
|
||||||
|
WithDisposable(LitElement)
|
||||||
|
) {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
max-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item {
|
||||||
|
display: inline;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 11px 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: ${unsafeCSSVarV2('text/primary')};
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
/* Body/Regular */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: -0.43px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.active {
|
||||||
|
color: ${unsafeCSSVarV2('text/emphasis')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item:active {
|
||||||
|
background: var(--affine-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.title,
|
||||||
|
.outline-menu-item.h1 {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.h2 {
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.h3 {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.h4 {
|
||||||
|
padding-left: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.h5 {
|
||||||
|
padding-left: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-menu-item.h6 {
|
||||||
|
padding-left: 108px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private readonly _activeHeadingId$ = signal<string | null>(null);
|
||||||
|
|
||||||
|
private _highlightMaskDisposable = () => {};
|
||||||
|
|
||||||
|
private _lockActiveHeadingId = false;
|
||||||
|
|
||||||
|
private async _scrollToBlock(blockId: string) {
|
||||||
|
this._lockActiveHeadingId = true;
|
||||||
|
this._activeHeadingId$.value = blockId;
|
||||||
|
this._highlightMaskDisposable = await scrollToBlockWithHighlight(
|
||||||
|
this.editor,
|
||||||
|
blockId
|
||||||
|
);
|
||||||
|
this._lockActiveHeadingId = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
observeActiveHeadingDuringScroll(
|
||||||
|
() => this.editor,
|
||||||
|
newHeadingId => {
|
||||||
|
if (this._lockActiveHeadingId) return;
|
||||||
|
this._activeHeadingId$.value = newHeadingId;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._highlightMaskDisposable();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem = (item: BlockModel) => {
|
||||||
|
let className = '';
|
||||||
|
let text = '';
|
||||||
|
if (matchFlavours(item, ['affine:page'])) {
|
||||||
|
className = 'title';
|
||||||
|
text = item.title$.value.toString();
|
||||||
|
} else if (
|
||||||
|
matchFlavours(item, ['affine:paragraph']) &&
|
||||||
|
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(item.type$.value)
|
||||||
|
) {
|
||||||
|
className = item.type$.value;
|
||||||
|
text = item.text$.value.toString();
|
||||||
|
} else {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div
|
||||||
|
class=${classMap({
|
||||||
|
'outline-menu-item': true,
|
||||||
|
[className]: true,
|
||||||
|
active: this._activeHeadingId$.value === item.id,
|
||||||
|
})}
|
||||||
|
@click=${() => {
|
||||||
|
this._scrollToBlock(item.id).catch(console.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${text}
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.editor.doc.root === null || this.editor.mode === 'edgeless')
|
||||||
|
return nothing;
|
||||||
|
|
||||||
|
const headingBlocks = getHeadingBlocksFromDoc(
|
||||||
|
this.editor.doc,
|
||||||
|
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (headingBlocks.length === 0) return nothing;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(this.editor.doc.meta?.title !== '' ? [this.editor.doc.root] : []),
|
||||||
|
...headingBlocks,
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${repeat(items, block => block.id, this.renderItem)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor editor!: AffineEditorContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
[AFFINE_MOBILE_OUTLINE_MENU]: MobileOutlineMenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,33 +53,28 @@ export const observeActiveHeadingDuringScroll = (
|
|||||||
getEditor: () => AffineEditorContainer, // workaround for editor changed
|
getEditor: () => AffineEditorContainer, // workaround for editor changed
|
||||||
update: (activeHeading: string | null) => void
|
update: (activeHeading: string | null) => void
|
||||||
) => {
|
) => {
|
||||||
const editor = getEditor();
|
const handler = () => {
|
||||||
update(editor.doc.root?.id ?? null);
|
const { host } = getEditor();
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const headings = getHeadingBlocksFromDoc(
|
||||||
|
host.doc,
|
||||||
|
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
let activeHeadingId = host.doc.root?.id ?? null;
|
||||||
|
headings.forEach(heading => {
|
||||||
|
if (isBlockBeforeViewportCenter(heading.id, host)) {
|
||||||
|
activeHeadingId = heading.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
update(activeHeadingId);
|
||||||
|
};
|
||||||
|
handler();
|
||||||
|
|
||||||
const disposables = new DisposableGroup();
|
const disposables = new DisposableGroup();
|
||||||
disposables.addFromEvent(
|
disposables.addFromEvent(window, 'scroll', handler, true);
|
||||||
window,
|
|
||||||
'scroll',
|
|
||||||
() => {
|
|
||||||
const { host } = getEditor();
|
|
||||||
if (!host) return;
|
|
||||||
|
|
||||||
const headings = getHeadingBlocksFromDoc(
|
|
||||||
host.doc,
|
|
||||||
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
let activeHeadingId = host.doc.root?.id ?? null;
|
|
||||||
headings.forEach(heading => {
|
|
||||||
if (isBlockBeforeViewportCenter(heading.id, host)) {
|
|
||||||
activeHeadingId = heading.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
update(activeHeadingId);
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
return disposables;
|
return disposables;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { ReactNode } from 'react';
|
|||||||
export interface MenuProps {
|
export interface MenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
items: ReactNode;
|
items: ReactNode;
|
||||||
|
title?: string;
|
||||||
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
||||||
rootOptions?: Omit<DropdownMenuProps, 'children'>;
|
rootOptions?: Omit<DropdownMenuProps, 'children'>;
|
||||||
contentOptions?: Omit<DropdownMenuContentProps, 'children'>;
|
contentOptions?: Omit<DropdownMenuContentProps, 'children'>;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MobileMenuSubRaw } from './sub';
|
|||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
children,
|
children,
|
||||||
items,
|
items,
|
||||||
|
title,
|
||||||
contentOptions: {
|
contentOptions: {
|
||||||
className,
|
className,
|
||||||
onPointerDownOutside,
|
onPointerDownOutside,
|
||||||
@@ -108,7 +109,7 @@ export const MobileMenu = ({
|
|||||||
*/
|
*/
|
||||||
if (pSetOpen) {
|
if (pSetOpen) {
|
||||||
return (
|
return (
|
||||||
<MobileMenuSubRaw items={items} subOptions={rootOptions}>
|
<MobileMenuSubRaw title={title} items={items} subOptions={rootOptions}>
|
||||||
{children}
|
{children}
|
||||||
</MobileMenuSubRaw>
|
</MobileMenuSubRaw>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
type AffineEditorContainer,
|
||||||
|
MobileOutlineMenu,
|
||||||
|
} from '@blocksuite/affine/presets';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export const MobileTocMenu = ({
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
editor: AffineEditorContainer | null;
|
||||||
|
}) => {
|
||||||
|
const outlineMenuRef = useRef<MobileOutlineMenu | null>(null);
|
||||||
|
const onRefChange = useCallback((container: HTMLDivElement | null) => {
|
||||||
|
if (container) {
|
||||||
|
if (outlineMenuRef.current === null) {
|
||||||
|
console.error('mobile outline menu should be initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.append(outlineMenuRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
if (!outlineMenuRef.current) {
|
||||||
|
outlineMenuRef.current = new MobileOutlineMenu();
|
||||||
|
}
|
||||||
|
if (outlineMenuRef.current.editor !== editor) {
|
||||||
|
outlineMenuRef.current.editor = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={onRefChange} />;
|
||||||
|
};
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from '@affine/component/ui/menu';
|
} from '@affine/component/ui/menu';
|
||||||
import { useFavorite } from '@affine/core/components/blocksuite/block-suite-header/favorite';
|
import { useFavorite } from '@affine/core/components/blocksuite/block-suite-header/favorite';
|
||||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
import { EditorOutlinePanel } from '@affine/core/desktop/pages/workspace/detail-page/tabs/outline';
|
|
||||||
import { DocInfoSheet } from '@affine/core/mobile/components';
|
import { DocInfoSheet } from '@affine/core/mobile/components';
|
||||||
|
import { MobileTocMenu } from '@affine/core/mobile/components/toc-menu';
|
||||||
import { DocService } from '@affine/core/modules/doc';
|
import { DocService } from '@affine/core/modules/doc';
|
||||||
import { EditorService } from '@affine/core/modules/editor';
|
import { EditorService } from '@affine/core/modules/editor';
|
||||||
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
||||||
@@ -120,9 +120,10 @@ export const PageHeaderMenuButton = () => {
|
|||||||
<span>{t['com.affine.page-properties.page-info.view']()}</span>
|
<span>{t['com.affine.page-properties.page-info.view']()}</span>
|
||||||
</MenuSub>
|
</MenuSub>
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
|
title={t['com.affine.header.menu.toc']()}
|
||||||
items={
|
items={
|
||||||
<div className={styles.outlinePanel}>
|
<div className={styles.outlinePanel}>
|
||||||
<EditorOutlinePanel editor={editorContainer} />
|
<MobileTocMenu editor={editorContainer} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2079,6 +2079,10 @@ export function useAFFiNEI18N(): {
|
|||||||
* `View table of contents`
|
* `View table of contents`
|
||||||
*/
|
*/
|
||||||
["com.affine.header.option.view-toc"](): string;
|
["com.affine.header.option.view-toc"](): string;
|
||||||
|
/**
|
||||||
|
* `Table of contents`
|
||||||
|
*/
|
||||||
|
["com.affine.header.menu.toc"](): string;
|
||||||
/**
|
/**
|
||||||
* `Contact us`
|
* `Contact us`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -519,6 +519,7 @@
|
|||||||
"com.affine.header.option.open-in-desktop": "Open in desktop app",
|
"com.affine.header.option.open-in-desktop": "Open in desktop app",
|
||||||
"com.affine.header.option.view-frame": "View all frames",
|
"com.affine.header.option.view-frame": "View all frames",
|
||||||
"com.affine.header.option.view-toc": "View table of contents",
|
"com.affine.header.option.view-toc": "View table of contents",
|
||||||
|
"com.affine.header.menu.toc": "Table of contents",
|
||||||
"com.affine.helpIsland.contactUs": "Contact us",
|
"com.affine.helpIsland.contactUs": "Contact us",
|
||||||
"com.affine.helpIsland.gettingStarted": "Getting started",
|
"com.affine.helpIsland.gettingStarted": "Getting started",
|
||||||
"com.affine.helpIsland.helpAndFeedback": "Help and feedback",
|
"com.affine.helpIsland.helpAndFeedback": "Help and feedback",
|
||||||
|
|||||||
Reference in New Issue
Block a user