diff --git a/blocksuite/presets/src/effects.ts b/blocksuite/presets/src/effects.ts index 758a5ffe3d..da471e3225 100644 --- a/blocksuite/presets/src/effects.ts +++ b/blocksuite/presets/src/effects.ts @@ -34,11 +34,13 @@ import { } from './fragments/frame-panel/header/frames-setting-menu.js'; import { AFFINE_FRAME_PANEL, + AFFINE_MOBILE_OUTLINE_MENU, AFFINE_OUTLINE_PANEL, AFFINE_OUTLINE_VIEWER, CommentPanel, DocTitle, FramePanel, + MobileOutlineMenu, OutlinePanel, OutlineViewer, } from './fragments/index.js'; @@ -87,6 +89,7 @@ export function effects() { customElements.define('edgeless-editor', EdgelessEditor); customElements.define(AFFINE_FRAME_CARD, FrameCard); customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer); + customElements.define(AFFINE_MOBILE_OUTLINE_MENU, MobileOutlineMenu); customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle); customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview); customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody); diff --git a/blocksuite/presets/src/fragments/outline/index.ts b/blocksuite/presets/src/fragments/outline/index.ts index 01f75720b3..1f9fcfbbd5 100644 --- a/blocksuite/presets/src/fragments/outline/index.ts +++ b/blocksuite/presets/src/fragments/outline/index.ts @@ -1,2 +1,3 @@ +export * from './mobile-outline-panel.js'; export * from './outline-panel.js'; export * from './outline-viewer.js'; diff --git a/blocksuite/presets/src/fragments/outline/mobile-outline-panel.ts b/blocksuite/presets/src/fragments/outline/mobile-outline-panel.ts new file mode 100644 index 0000000000..9f2de1ce28 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/mobile-outline-panel.ts @@ -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(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`
{ + this._scrollToBlock(item.id).catch(console.error); + }} + > + ${text} +
`; + }; + + 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)} + + `; + } + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_MOBILE_OUTLINE_MENU]: MobileOutlineMenu; + } +} diff --git a/blocksuite/presets/src/fragments/outline/utils/scroll.ts b/blocksuite/presets/src/fragments/outline/utils/scroll.ts index fd1884c227..3dea5f6516 100644 --- a/blocksuite/presets/src/fragments/outline/utils/scroll.ts +++ b/blocksuite/presets/src/fragments/outline/utils/scroll.ts @@ -53,33 +53,28 @@ export const observeActiveHeadingDuringScroll = ( getEditor: () => AffineEditorContainer, // workaround for editor changed update: (activeHeading: string | null) => void ) => { - const editor = getEditor(); - update(editor.doc.root?.id ?? null); + const handler = () => { + 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(); - disposables.addFromEvent( - 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 - ); + disposables.addFromEvent(window, 'scroll', handler, true); return disposables; }; diff --git a/packages/frontend/component/src/ui/menu/menu.types.ts b/packages/frontend/component/src/ui/menu/menu.types.ts index ab04f7707d..723075028f 100644 --- a/packages/frontend/component/src/ui/menu/menu.types.ts +++ b/packages/frontend/component/src/ui/menu/menu.types.ts @@ -11,6 +11,7 @@ import type { ReactNode } from 'react'; export interface MenuProps { children: ReactNode; items: ReactNode; + title?: string; portalOptions?: Omit; rootOptions?: Omit; contentOptions?: Omit; diff --git a/packages/frontend/component/src/ui/menu/mobile/root.tsx b/packages/frontend/component/src/ui/menu/mobile/root.tsx index d5f731f413..ca56d59fa6 100644 --- a/packages/frontend/component/src/ui/menu/mobile/root.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/root.tsx @@ -20,6 +20,7 @@ import { MobileMenuSubRaw } from './sub'; export const MobileMenu = ({ children, items, + title, contentOptions: { className, onPointerDownOutside, @@ -108,7 +109,7 @@ export const MobileMenu = ({ */ if (pSetOpen) { return ( - + {children} ); diff --git a/packages/frontend/core/src/mobile/components/toc-menu/index.tsx b/packages/frontend/core/src/mobile/components/toc-menu/index.tsx new file mode 100644 index 0000000000..bdd8b07a26 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/toc-menu/index.tsx @@ -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(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
; +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-more-button.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-more-button.tsx index 32b5385169..3518340353 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-more-button.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-more-button.tsx @@ -7,8 +7,8 @@ import { } from '@affine/component/ui/menu'; import { useFavorite } from '@affine/core/components/blocksuite/block-suite-header/favorite'; 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 { MobileTocMenu } from '@affine/core/mobile/components/toc-menu'; import { DocService } from '@affine/core/modules/doc'; import { EditorService } from '@affine/core/modules/editor'; import { ViewService } from '@affine/core/modules/workbench/services/view'; @@ -120,9 +120,10 @@ export const PageHeaderMenuButton = () => { {t['com.affine.page-properties.page-info.view']()} - +
} > diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 5d689a3600..62a49641e5 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -2079,6 +2079,10 @@ export function useAFFiNEI18N(): { * `View table of contents` */ ["com.affine.header.option.view-toc"](): string; + /** + * `Table of contents` + */ + ["com.affine.header.menu.toc"](): string; /** * `Contact us` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index fc3c770c9a..78c75ce4b4 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -519,6 +519,7 @@ "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-toc": "View table of contents", + "com.affine.header.menu.toc": "Table of contents", "com.affine.helpIsland.contactUs": "Contact us", "com.affine.helpIsland.gettingStarted": "Getting started", "com.affine.helpIsland.helpAndFeedback": "Help and feedback",