From 02bcecde720b3cf5e774cc706b2fd14ebcb40c4f Mon Sep 17 00:00:00 2001 From: L-Sun Date: Thu, 23 Jan 2025 08:52:58 +0000 Subject: [PATCH] refactor(editor): simplify TOC implementation with signal and context (#9873) ### What Changes 1. Used `@preact/signal` and `@lit/context` to simplify repetitive passing of properties of TOC components, 2. Fixed TOC invalid when editor changed, such as click new page button. --- .../fragments/outline/body/outline-notice.ts | 64 +++-- .../outline/body/outline-panel-body.ts | 218 ++++++------------ .../outline/card/outline-card.css.ts | 10 +- .../fragments/outline/card/outline-card.ts | 44 ++-- .../fragments/outline/card/outline-preview.ts | 46 ++-- .../presets/src/fragments/outline/config.ts | 13 +- .../outline/header/outline-panel-header.ts | 58 +++-- .../outline/header/outline-setting-menu.ts | 24 +- .../src/fragments/outline/outline-panel.ts | 151 +++++------- .../src/fragments/outline/outline-viewer.ts | 25 +- .../src/fragments/outline/utils/drag.ts | 13 +- .../blocksuite/outline/outline-panel.spec.ts | 201 ++++++++++------ tests/kit/src/utils/editor.ts | 8 + tests/kit/src/utils/page-logic.ts | 1 + 14 files changed, 434 insertions(+), 442 deletions(-) diff --git a/blocksuite/presets/src/fragments/outline/body/outline-notice.ts b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts index 13e13ceb9..2d19aa2c1 100644 --- a/blocksuite/presets/src/fragments/outline/body/outline-notice.ts +++ b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts @@ -1,21 +1,49 @@ +import { NoteDisplayMode } from '@blocksuite/affine-model'; import { ShadowlessElement } from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { CloseIcon, SortIcon } from '@blocksuite/icons/lit'; +import { consume } from '@lit/context'; +import { effect, signal } from '@preact/signals-core'; import { html, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; +import { type TocContext, tocContext } from '../config'; +import { getNotesFromDoc } from '../utils/query'; import * as styles from './outline-notice.css'; export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice'; -export class OutlineNotice extends WithDisposable(ShadowlessElement) { - private _handleNoticeButtonClick() { - this.toggleNotesSorting(); - this.setNoticeVisibility(false); +export class OutlineNotice extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + private readonly _visible$ = signal(false); + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.add( + effect(() => { + const enableSorting = this._context.enableSorting$.value; + + if (enableSorting) { + if (this._visible$.peek()) { + this._visible$.value = false; + } + return; + } + + const shouldShowNotice = + getNotesFromDoc(this._context.editor$.value.doc, [ + NoteDisplayMode.DocOnly, + ]).length > 0; + + if (shouldShowNotice && !this._visible$.peek()) { + this._visible$.value = true; + } + }) + ); } override render() { - if (!this.noticeVisible) { + if (!this._visible$.value) { return nothing; } @@ -25,7 +53,9 @@ export class OutlineNotice extends WithDisposable(ShadowlessElement) { SOME CONTENTS HIDDEN this.setNoticeVisibility(false)} + @click=${() => { + this._visible$.value = false; + }} >${CloseIcon({ width: '16px', height: '16px' })} @@ -33,7 +63,13 @@ export class OutlineNotice extends WithDisposable(ShadowlessElement) {
Some contents are not visible on edgeless.
-
+
{ + this._context.enableSorting$.value = true; + this._visible$.value = false; + }} + > Click here or ${SortIcon({ width: '20px', height: '20px' })} void; - - @property({ attribute: false }) - accessor toggleNotesSorting!: () => void; + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts b/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts index 9fa67bdf3..3849ae355 100644 --- a/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts +++ b/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts @@ -5,22 +5,16 @@ import { matchFlavours, NoteDisplayMode, } from '@blocksuite/blocks'; -import { - Bound, - DisposableGroup, - SignalWatcher, - WithDisposable, -} from '@blocksuite/global/utils'; +import { Bound, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { consume } from '@lit/context'; import { effect, signal } from '@preact/signals-core'; -import { html, nothing, type PropertyValues } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { html, nothing } from 'lit'; +import { query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; import { when } from 'lit/directives/when.js'; -import type { AffineEditorContainer } from '../../../editors/editor-container'; -import { editorContext } from '../config'; +import { type TocContext, tocContext } from '../config'; import type { ClickBlockEvent, DisplayModeChangeEvent, @@ -60,6 +54,8 @@ export class OutlinePanelBody extends SignalWatcher( ) { private readonly _activeHeadingId$ = signal(null); + private readonly _insertIndex$ = signal(undefined); + private readonly _dragging$ = signal(false); private readonly _pageVisibleNoteItems$ = signal([]); @@ -68,8 +64,6 @@ export class OutlinePanelBody extends SignalWatcher( private _clearHighlightMask = () => {}; - private _docDisposables: DisposableGroup | null = null; - private _indicatorTranslateY = 0; private _lockActiveHeadingId = false; @@ -81,6 +75,10 @@ export class OutlinePanelBody extends SignalWatcher( ); } + private get editor() { + return this._context.editor$.value; + } + private get doc() { return this.editor.doc; } @@ -90,18 +88,14 @@ export class OutlinePanelBody extends SignalWatcher( } get viewportPadding(): [number, number, number, number] { - return this.fitPadding + const fitPadding = this._context.fitPadding$.value; + return fitPadding.length === 4 ? ([0, 0, 0, 0].map((val, idx) => - Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val + Number.isFinite(fitPadding[idx]) ? fitPadding[idx] : val ) as [number, number, number, number]) : [0, 0, 0, 0]; } - private _clearDocDisposables() { - this._docDisposables?.dispose(); - this._docDisposables = null; - } - private _deSelectNoteInEdgelessMode(note: NoteBlockModel) { if (!this.edgeless) return; @@ -119,15 +113,16 @@ export class OutlinePanelBody extends SignalWatcher( */ private readonly _doubleClickHandler = (e: MouseEvent) => { e.stopPropagation(); + const enableSorting = this._context.enableSorting$.peek(); // check if click at outline-card, if so, do nothing if ( (e.target as HTMLElement).closest('outline-note-card') || - !this.enableNotesSorting + !enableSorting ) { return; } - this.toggleNotesSorting(); + this._context.enableSorting$.value = !enableSorting; }; private _drag() { @@ -178,7 +173,7 @@ export class OutlinePanelBody extends SignalWatcher( outlineListContainer: this._pageVisibleList, onDragEnd: insertIdx => { this._dragging$.value = false; - this.insertIndex = undefined; + this._insertIndex$.value = undefined; if (insertIdx === undefined) return; @@ -191,7 +186,7 @@ export class OutlinePanelBody extends SignalWatcher( ); }, onDragMove: (idx, indicatorTranslateY) => { - this.insertIndex = idx; + this._insertIndex$.value = idx; this._indicatorTranslateY = indicatorTranslateY ?? 0; }, }); @@ -303,20 +298,15 @@ export class OutlinePanelBody extends SignalWatcher( if (!hasNotEmptyHeadings) return nothing; + const rootId = this.doc.root.id; + const active = rootId === this._activeHeadingId$.value; + return html` { - if (!this.doc.root) return; - this._scrollToBlock(this.doc.root.id).catch(console.error); + this._scrollToBlock(rootId).catch(console.error); }} >`; } @@ -332,8 +322,6 @@ export class OutlinePanelBody extends SignalWatcher( .number=${idx + 1} .index=${item.index} .activeHeadingId=${this._activeHeadingId$.value} - .showPreviewIcon=${this.showPreviewIcon} - .enableNotesSorting=${this.enableNotesSorting} .status=${this._selectedNotes$.value.includes(item.note.id) ? this._dragging$.value ? 'placeholder' @@ -353,7 +341,7 @@ export class OutlinePanelBody extends SignalWatcher( private _renderPageVisibleCardList() { return html`
${when( - this.insertIndex !== undefined, + this._insertIndex$.value !== undefined, () => html`
{ - this._updateNoticeVisibility(); - }) - ); - - this._docDisposables.add( - effect(() => { - this._updateNotes(); - }) - ); - } - - private _updateNotes() { - if (this._dragging$.value) return; - - const isRenderableNote = (item: OutlineNoteItem) => { - let hasHeadings = false; - - for (const block of item.note.children) { - if (isHeadingBlock(block)) { - hasHeadings = true; - break; - } - } - - return hasHeadings || this.enableNotesSorting; - }; - - this._pageVisibleNoteItems$.value = getNotesFromDoc(this.doc, [ - NoteDisplayMode.DocAndEdgeless, - NoteDisplayMode.DocOnly, - ]).filter(isRenderableNote); - - this._edgelessOnlyNoteItems$.value = getNotesFromDoc(this.doc, [ - NoteDisplayMode.EdgelessOnly, - ]).filter(isRenderableNote); - } - - private _updateNoticeVisibility() { - if (this.enableNotesSorting) { - if (this.noticeVisible) { - this.setNoticeVisibility(false); - } - return; - } - - const shouldShowNotice = - getNotesFromDoc(this.doc, [NoteDisplayMode.DocOnly]).length > 0; - - if (shouldShowNotice && !this.noticeVisible) { - this.setNoticeVisibility(true); - } - } - private _watchSelectedNotes() { + return effect(() => { + const { std, doc, mode } = this.editor; + if (mode !== 'edgeless') return; + + const currSelectedNotes = std.selection + .filter(SurfaceSelection) + .filter(({ blockId }) => { + const model = doc.getBlock(blockId)?.model; + return !!model && matchFlavours(model, ['affine:note']); + }) + .map(({ blockId }) => blockId); + + const preSelected = this._selectedNotes$.peek(); + if ( + preSelected.length !== currSelectedNotes.length || + preSelected.some(id => !currSelectedNotes.includes(id)) + ) { + this._selectedNotes$.value = currSelectedNotes; + } + }); + } + + private _watchNotes() { this.disposables.add( effect(() => { - const { std, doc, mode } = this.editor; - if (mode !== 'edgeless') return; + if (this._dragging$.value) return; - const currSelectedNotes = std.selection - .filter(SurfaceSelection) - .filter(({ blockId }) => { - const model = doc.getBlock(blockId)?.model; - return !!model && matchFlavours(model, ['affine:note']); - }) - .map(({ blockId }) => blockId); + const isRenderableNote = (item: OutlineNoteItem) => { + let hasHeadings = false; - const preSelected = this._selectedNotes$.peek(); - if ( - preSelected.length !== currSelectedNotes.length || - preSelected.some(id => !currSelectedNotes.includes(id)) - ) { - this._selectedNotes$.value = currSelectedNotes; - } + for (const block of item.note.children) { + if (isHeadingBlock(block)) { + hasHeadings = true; + break; + } + } + return hasHeadings || this._context.enableSorting$.value; + }; + + this._pageVisibleNoteItems$.value = getNotesFromDoc(this.doc, [ + NoteDisplayMode.DocAndEdgeless, + NoteDisplayMode.DocOnly, + ]).filter(isRenderableNote); + + this._edgelessOnlyNoteItems$.value = getNotesFromDoc(this.doc, [ + NoteDisplayMode.EdgelessOnly, + ]).filter(isRenderableNote); }) ); } @@ -507,12 +464,12 @@ export class OutlinePanelBody extends SignalWatcher( } ) ); + this._watchNotes(); this._watchSelectedNotes(); } override disconnectedCallback(): void { super.disconnectedCallback(); - this._clearDocDisposables(); this._clearHighlightMask(); } @@ -534,46 +491,11 @@ export class OutlinePanelBody extends SignalWatcher( `; } - override willUpdate(_changedProperties: PropertyValues) { - if (_changedProperties.has('editor')) { - this._setDocDisposables(); - } - - if (_changedProperties.has('enableNotesSorting')) { - this._updateNoticeVisibility(); - } - } - @query('.page-visible-card-list') private accessor _pageVisibleList!: HTMLElement; - @consume({ context: editorContext }) - @property({ attribute: false }) - accessor editor!: AffineEditorContainer; - - @property({ attribute: false }) - accessor enableNotesSorting: boolean = false; - - @property({ attribute: false }) - accessor fitPadding!: number[]; - - @property({ attribute: false }) - accessor insertIndex: number | undefined = undefined; - - @property({ attribute: false }) - accessor noticeVisible!: boolean; - - @property({ attribute: false }) - accessor renderEdgelessOnlyNotes: boolean = true; - - @property({ attribute: false }) - accessor setNoticeVisibility!: (visibility: boolean) => void; - - @property({ attribute: false }) - accessor showPreviewIcon!: boolean; - - @property({ attribute: false }) - accessor toggleNotesSorting!: () => void; + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts b/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts index 38e4919df..38e0999a5 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts @@ -48,19 +48,23 @@ export const cardHeader = style({ alignItems: 'center', gap: '8px', boxSizing: 'border-box', + + ':hover': { + cursor: 'grab', + }, selectors: { [`${outlineCard}[data-sortable="true"] &`]: { display: 'flex', }, - [`${outlineCard}[data-invisible="false"] &:hover`]: { - cursor: 'grab', + [`${outlineCard}[data-visibility="edgeless"] &:hover`]: { + cursor: 'default', }, }, }); const invisibleCard = style({ selectors: { - [`${outlineCard}[data-invisible="true"] &`]: { + [`${outlineCard}[data-visibility="edgeless"] &`]: { color: cssVarV2('text/disable'), pointerEvents: 'none', }, diff --git a/blocksuite/presets/src/fragments/outline/card/outline-card.ts b/blocksuite/presets/src/fragments/outline/card/outline-card.ts index cc96a7947..c53459847 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-card.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.ts @@ -9,9 +9,13 @@ import { import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit'; import type { BlockModel } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { signal } from '@preact/signals-core'; import { html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { type TocContext, tocContext } from '../config'; import type { SelectEvent } from '../utils/custom-events'; import * as styles from './outline-card.css'; @@ -23,6 +27,8 @@ export class OutlineNoteCard extends SignalWatcher( private _displayModePopper: ReturnType | null = null; + private readonly _showPopper$ = signal(false); + private _dispatchClickBlockEvent(block: BlockModel) { const event = new CustomEvent('clickblock', { detail: { @@ -49,7 +55,7 @@ export class OutlineNoteCard extends SignalWatcher( private _dispatchDragEvent(e: MouseEvent) { e.preventDefault(); - if (e.button !== 0 || !this.enableNotesSorting) return; + if (e.button !== 0 || !this._context.enableSorting$.peek()) return; const { clientX: startX, clientY: startY } = e; const disposeDragStart = on(this.ownerDocument, 'mousemove', e => { @@ -122,7 +128,7 @@ export class OutlineNoteCard extends SignalWatcher( this._displayModeButtonGroup, this._displayModePanel, ({ display }) => { - this._showPopper = display === 'show'; + this._showPopper$.value = display === 'show'; }, { mainAxis: 0, @@ -136,11 +142,15 @@ export class OutlineNoteCard extends SignalWatcher( override render() { const { children, displayMode } = this.note; const currentMode = this._getCurrentModeLabel(displayMode); + const invisible = + this.note.displayMode$.value === NoteDisplayMode.EdgelessOnly; + + const enableSorting = this._context.enableSorting$.value; return html`
@@ -152,7 +162,7 @@ export class OutlineNoteCard extends SignalWatcher( > ${html`
${ - this.invisible + invisible ? html`${InvisibleIcon({ width: '20px', height: '20px' })}` @@ -162,7 +172,7 @@ export class OutlineNoteCard extends SignalWatcher(
Show in ${children.map(block => { return html` { - if (this.invisible) return; + if (invisible) return; this._dispatchClickBlockEvent(block); }} >`; @@ -218,15 +226,9 @@ export class OutlineNoteCard extends SignalWatcher( @query('note-display-mode-panel') private accessor _displayModePanel!: HTMLDivElement; - @state() - private accessor _showPopper = false; - @property({ attribute: false }) accessor activeHeadingId: string | null = null; - @property({ attribute: false }) - accessor enableNotesSorting!: boolean; - @property({ attribute: false }) accessor index!: number; @@ -236,11 +238,11 @@ export class OutlineNoteCard extends SignalWatcher( @property({ attribute: false }) accessor number!: number; - @property({ attribute: false }) - accessor showPreviewIcon!: boolean; - @property({ attribute: false }) accessor status: 'selected' | 'placeholder' | 'normal' = 'normal'; + + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/card/outline-preview.ts b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts index 0e89418fc..32c86dc4d 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-preview.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts @@ -19,8 +19,12 @@ import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import type { AffineEditorContainer } from '../../../editors/editor-container.js'; -import { editorContext, placeholderMap, previewIconMap } from '../config.js'; +import { + placeholderMap, + previewIconMap, + type TocContext, + tocContext, +} from '../config.js'; import { isHeadingBlock, isRootBlock } from '../utils/query.js'; import * as styles from './outline-preview.css'; @@ -35,6 +39,10 @@ export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview'; export class OutlineBlockPreview extends SignalWatcher( WithDisposable(ShadowlessElement) ) { + private get _docDisplayMetaService() { + return this._context.editor$.value.std.get(DocDisplayMetaProvider); + } + private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) { const deltas: DeltaInsert[] = block.text.yText.toDelta(); @@ -49,16 +57,13 @@ export class OutlineBlockPreview extends SignalWatcher( doc => doc.id === refAttribute.pageId ); const unavailable = !refMeta; - const docDisplayMetaService = this.editor.std.get( - DocDisplayMetaProvider - ); const icon = unavailable ? LinkedPageIcon({ width: '1.1em', height: '1.1em' }) - : docDisplayMetaService.icon(refMeta.id).value; + : this._docDisplayMetaService.icon(refMeta.id).value; const title = unavailable ? 'Deleted doc' - : docDisplayMetaService.title(refMeta.id).value; + : this._docDisplayMetaService.title(refMeta.id).value; return html`${previewText} - ${this.showPreviewIcon + ${this._context.showIcons$.value ? html`${previewIconMap[block.type]}` : nothing}`; } @@ -110,12 +115,14 @@ export class OutlineBlockPreview extends SignalWatcher( const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon; if ( - !this.enableNotesSorting && + !this._context.enableSorting$.value && !isHeadingBlock(block) && !isRootBlock(block) ) return nothing; + const showPreviewIcon = this._context.showIcons$.value; + switch (block.flavour as keyof BlockSuite.BlockModels) { case 'affine:page': assertType(block); @@ -139,7 +146,7 @@ export class OutlineBlockPreview extends SignalWatcher( ${block.title || block.url || placeholderMap['bookmark']} - ${this.showPreviewIcon + ${showPreviewIcon ? html`${previewIconMap['bookmark']}` @@ -151,7 +158,7 @@ export class OutlineBlockPreview extends SignalWatcher( ${block.language ?? placeholderMap['code']} - ${this.showPreviewIcon + ${showPreviewIcon ? html`${previewIconMap['code']}` : nothing} `; @@ -163,7 +170,7 @@ export class OutlineBlockPreview extends SignalWatcher( ? block.title.toString() : placeholderMap['database']} - ${this.showPreviewIcon + ${showPreviewIcon ? html`${previewIconMap['table']}` : nothing} `; @@ -175,7 +182,7 @@ export class OutlineBlockPreview extends SignalWatcher( ? block.caption : placeholderMap['image']} - ${this.showPreviewIcon + ${showPreviewIcon ? html`${previewIconMap['image']}` : nothing} `; @@ -187,7 +194,7 @@ export class OutlineBlockPreview extends SignalWatcher( ? block.name : placeholderMap['attachment']} - ${this.showPreviewIcon + ${showPreviewIcon ? html`${previewIconMap['attachment']}` @@ -198,10 +205,6 @@ export class OutlineBlockPreview extends SignalWatcher( } } - @consume({ context: editorContext }) - @property({ attribute: false }) - accessor editor!: AffineEditorContainer; - @property({ attribute: false }) accessor block!: ValuesOf; @@ -211,11 +214,8 @@ export class OutlineBlockPreview extends SignalWatcher( @property({ attribute: false }) accessor disabledIcon = false; - @property({ attribute: false }) - accessor enableNotesSorting!: boolean; - - @property({ attribute: false }) - accessor showPreviewIcon!: boolean; + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/config.ts b/blocksuite/presets/src/fragments/outline/config.ts index a1c0ba7ba..b5a4e6d8f 100644 --- a/blocksuite/presets/src/fragments/outline/config.ts +++ b/blocksuite/presets/src/fragments/outline/config.ts @@ -1,4 +1,4 @@ -import type { ParagraphBlockModel } from '@blocksuite/blocks'; +import type { ParagraphBlockModel, Signal } from '@blocksuite/blocks'; import { AttachmentIcon, BlockIcon, @@ -84,10 +84,11 @@ export const headingKeys = new Set( export const outlineSettingsKey = 'outlinePanelSettings'; -export type OutlineSettingsDataType = { - showIcons: boolean; - enableSorting: boolean; +export type TocContext = { + editor$: Signal; + enableSorting$: Signal; + showIcons$: Signal; + fitPadding$: Signal; }; -export const editorContext = - createContext('editorContext'); +export const tocContext = createContext('tocContext'); diff --git a/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts b/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts index ad00ffd40..95718aea9 100644 --- a/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts +++ b/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts @@ -1,45 +1,54 @@ import { ShadowlessElement } from '@blocksuite/block-std'; import { createButtonPopper } from '@blocksuite/blocks'; -import { WithDisposable } from '@blocksuite/global/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SettingsIcon, SortIcon } from '@blocksuite/icons/lit'; +import { consume } from '@lit/context'; +import { signal } from '@preact/signals-core'; import { html } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { query } from 'lit/decorators.js'; +import { type TocContext, tocContext } from '../config'; import * as styles from './outline-panel-header.css'; export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header'; -export class OutlinePanelHeader extends WithDisposable(ShadowlessElement) { +export class OutlinePanelHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { private _notePreviewSettingMenuPopper: ReturnType< typeof createButtonPopper > | null = null; - override firstUpdated() { - const _disposables = this._disposables; + private readonly _settingPopperShow$ = signal(false); + override firstUpdated() { this._notePreviewSettingMenuPopper = createButtonPopper( this._noteSettingButton, this._notePreviewSettingMenu, ({ display }) => { - this._settingPopperShow = display === 'show'; + this._settingPopperShow$.value = display === 'show'; }, { mainAxis: 14, crossAxis: -30, } ); - _disposables.add(this._notePreviewSettingMenuPopper); + this.disposables.add(this._notePreviewSettingMenuPopper); } override render() { + const sortingEnabled = this._context.enableSorting$.value; + const showSettingPopper = this._settingPopperShow$.value; + return html`
Table of Contents this._notePreviewSettingMenuPopper?.toggle()} > @@ -48,22 +57,21 @@ export class OutlinePanelHeader extends WithDisposable(ShadowlessElement) {
this.toggleNotesSorting()} + @click=${() => { + this._context.enableSorting$.value = !sortingEnabled; + }} > ${SortIcon({ width: '20px', height: '20px' })}
- +
`; } @@ -73,20 +81,8 @@ export class OutlinePanelHeader extends WithDisposable(ShadowlessElement) { @query(`.${styles.noteSettingContainer}`) private accessor _noteSettingButton!: HTMLDivElement; - @state() - private accessor _settingPopperShow = false; - - @property({ attribute: false }) - accessor enableNotesSorting!: boolean; - - @property({ attribute: false }) - accessor showPreviewIcon!: boolean; - - @property({ attribute: false }) - accessor toggleNotesSorting!: () => void; - - @property({ attribute: false }) - accessor toggleShowPreviewIcon!: (on: boolean) => void; + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts b/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts index 5e31717a1..171f50d4a 100644 --- a/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts +++ b/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts @@ -1,17 +1,20 @@ import { ShadowlessElement } from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; import { html } from 'lit'; -import { property } from 'lit/decorators.js'; +import { type TocContext, tocContext } from '../config'; import * as styles from './outline-setting-menu.css'; export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU = 'affine-outline-note-preview-setting-menu'; -export class OutlineNotePreviewSettingMenu extends WithDisposable( - ShadowlessElement +export class OutlineNotePreviewSettingMenu extends SignalWatcher( + WithDisposable(ShadowlessElement) ) { override render() { + const showPreviewIcon = this._context.showIcons$.value; + return html`
e.stopPropagation()} @@ -23,19 +26,18 @@ export class OutlineNotePreviewSettingMenu extends WithDisposable(
Show type icon
{ + this._context.showIcons$.value = !showPreviewIcon; + }} >
`; } - @property({ attribute: false }) - accessor showPreviewIcon!: boolean; - - @property({ attribute: false }) - accessor toggleShowPreviewIcon!: (on: boolean) => void; + @consume({ context: tocContext }) + private accessor _context!: TocContext; } declare global { diff --git a/blocksuite/presets/src/fragments/outline/outline-panel.ts b/blocksuite/presets/src/fragments/outline/outline-panel.ts index b6e6c3138..49ea790d0 100644 --- a/blocksuite/presets/src/fragments/outline/outline-panel.ts +++ b/blocksuite/presets/src/fragments/outline/outline-panel.ts @@ -5,16 +5,12 @@ import { } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { provide } from '@lit/context'; -import { effect } from '@preact/signals-core'; +import { effect, signal } from '@preact/signals-core'; import { html, type PropertyValues } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import type { AffineEditorContainer } from '../../editors/editor-container.js'; -import { - editorContext, - type OutlineSettingsDataType, - outlineSettingsKey, -} from './config.js'; +import { outlineSettingsKey, type TocContext, tocContext } from './config.js'; import * as styles from './outline-panel.css'; export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel'; @@ -25,120 +21,81 @@ export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel'; export class OutlinePanel extends SignalWatcher( WithDisposable(ShadowlessElement) ) { - private readonly _setNoticeVisibility = (visibility: boolean) => { - this._noticeVisible = visibility; - }; + private _setContext() { + this._context = { + editor$: signal(this.editor), + showIcons$: signal(false), + enableSorting$: signal(false), + fitPadding$: signal(this.fitPadding), + }; - private _settings: OutlineSettingsDataType = { - showIcons: false, - enableSorting: false, - }; + this.disposables.add( + effect(() => { + const settingsString = localStorage.getItem(outlineSettingsKey); + const settings = settingsString ? JSON.parse(settingsString) : null; - private readonly _toggleNotesSorting = () => { - this._enableNotesSorting = !this._enableNotesSorting; - this._updateAndSaveSettings({ enableSorting: this._enableNotesSorting }); - }; + if (settings) { + this._context.showIcons$.value = settings.showIcons; + } - private readonly _toggleShowPreviewIcon = (on: boolean) => { - this._showPreviewIcon = on; - this._updateAndSaveSettings({ showIcons: on }); - }; - - get doc() { - return this.editor.doc; + const editor = this._context.editor$.value; + if (editor.mode === 'edgeless') { + this._context.enableSorting$.value = true; + } else if (settings) { + this._context.enableSorting$.value = settings.enableSorting; + } + }) + ); } - get host() { - return this.editor.host; - } + private _watchSettingsChange() { + this.disposables.add( + effect(() => { + if (this._context.editor$.value.mode === 'edgeless') return; - get mode() { - return this.editor.mode; - } - - private _loadSettingsFromLocalStorage() { - const settings = localStorage.getItem(outlineSettingsKey); - if (settings) { - this._settings = JSON.parse(settings); - this._showPreviewIcon = this._settings.showIcons; - this._enableNotesSorting = this._settings.enableSorting; - } - } - - private _saveSettingsToLocalStorage() { - localStorage.setItem(outlineSettingsKey, JSON.stringify(this._settings)); - } - - private _updateAndSaveSettings( - newSettings: Partial - ) { - this._settings = { ...this._settings, ...newSettings }; - this._saveSettingsToLocalStorage(); + const showPreviewIcon = this._context.showIcons$.value; + const enableNotesSorting = this._context.enableSorting$.value; + localStorage.setItem( + outlineSettingsKey, + JSON.stringify({ + showIcons: showPreviewIcon, + enableSorting: enableNotesSorting, + }) + ); + }) + ); } override connectedCallback() { super.connectedCallback(); this.classList.add(styles.outlinePanel); - this.disposables.add( - effect(() => { - if (this.editor.mode === 'edgeless') { - this._enableNotesSorting = true; - } else { - this._loadSettingsFromLocalStorage(); - } - }) - ); + this._setContext(); + this._watchSettingsChange(); } - override willUpdate(_changedProperties: PropertyValues): void { - if (_changedProperties.has('editor')) { - if (this.editor.mode === 'edgeless') { - this._enableNotesSorting = true; - } else { - this._loadSettingsFromLocalStorage(); - } + override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('editor')) { + this._context.editor$.value = this.editor; + } + if (changedProperties.has('fitPadding')) { + this._context.fitPadding$.value = this.fitPadding; } } override render() { - if (!this.host) return; + if (!this.editor.host) return; return html` - - - - + + + `; } - @state() - private accessor _enableNotesSorting = false; + @provide({ context: tocContext }) + private accessor _context!: TocContext; - @state() - private accessor _noticeVisible = false; - - @state() - private accessor _showPreviewIcon = false; - - @provide({ context: editorContext }) @property({ attribute: false }) accessor editor!: AffineEditorContainer; diff --git a/blocksuite/presets/src/fragments/outline/outline-viewer.ts b/blocksuite/presets/src/fragments/outline/outline-viewer.ts index d55b8194b..4bdbf0474 100644 --- a/blocksuite/presets/src/fragments/outline/outline-viewer.ts +++ b/blocksuite/presets/src/fragments/outline/outline-viewer.ts @@ -8,13 +8,13 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { TocIcon } from '@blocksuite/icons/lit'; import { provide } from '@lit/context'; import { signal } from '@preact/signals-core'; -import { css, html, nothing } from 'lit'; +import { css, html, nothing, type PropertyValues } from 'lit'; import { property, query, state } 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 { editorContext } from './config.js'; +import { type TocContext, tocContext } from './config.js'; import { getHeadingBlocksFromDoc } from './utils/query.js'; import { observeActiveHeadingDuringScroll, @@ -176,6 +176,15 @@ export class OutlineViewer extends SignalWatcher( } } + private _setContext() { + this._context = { + editor$: signal(this.editor), + showIcons$: signal(false), + enableSorting$: signal(false), + fitPadding$: signal([]), + }; + } + override connectedCallback() { super.connectedCallback(); @@ -194,6 +203,14 @@ export class OutlineViewer extends SignalWatcher( this.requestUpdate(); }) ); + + this._setContext(); + } + + override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('editor')) { + this._context.editor$.value = this.editor; + } } override disconnectedCallback() { @@ -281,13 +298,15 @@ export class OutlineViewer extends SignalWatcher( `; } + @provide({ context: tocContext }) + private accessor _context!: TocContext; + @query('.outline-viewer-item.active') private accessor _activeItem: HTMLElement | null = null; @state() private accessor _showViewer: boolean = false; - @provide({ context: editorContext }) @property({ attribute: false }) accessor editor!: AffineEditorContainer; diff --git a/blocksuite/presets/src/fragments/outline/utils/drag.ts b/blocksuite/presets/src/fragments/outline/utils/drag.ts index 3ecbee076..642aec906 100644 --- a/blocksuite/presets/src/fragments/outline/utils/drag.ts +++ b/blocksuite/presets/src/fragments/outline/utils/drag.ts @@ -1,4 +1,4 @@ -import { on, once } from '@blocksuite/blocks'; +import { NoteDisplayMode, on, once } from '@blocksuite/blocks'; import type { Store } from '@blocksuite/store'; import type { OutlinePanelBody } from '../body/outline-panel-body.js'; @@ -52,13 +52,14 @@ export function startDragging(options: { } idx = 0; - for (const note of children) { - if (note.invisible || !note.note) break; + for (const card of children) { + if (!card.note || card.note.displayMode === NoteDisplayMode.EdgelessOnly) + break; const topBoundary = - listContainerRect.top + note.offsetTop - outlineListContainer.scrollTop; - const midBoundary = topBoundary + note.offsetHeight / 2; - const bottomBoundary = topBoundary + note.offsetHeight; + listContainerRect.top + card.offsetTop - outlineListContainer.scrollTop; + const midBoundary = topBoundary + card.offsetHeight / 2; + const bottomBoundary = topBoundary + card.offsetHeight; if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) { idx = e.clientY > midBoundary ? idx + 1 : idx; diff --git a/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts b/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts index f61d3d27c..881af46ab 100644 --- a/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts +++ b/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts @@ -4,6 +4,7 @@ import { clickPageModeButton, clickView, createEdgelessNoteBlock, + focusDocTitle, locateElementToolbar, } from '@affine-test/kit/utils/editor'; import { @@ -33,8 +34,10 @@ import { } from './utils'; async function openTocPanel(page: Page) { - await openRightSideBar(page, 'outline'); const toc = page.locator('affine-outline-panel'); + if (await toc.isVisible()) return toc; + + await openRightSideBar(page, 'outline'); await toc.waitFor({ state: 'visible' }); return toc; } @@ -43,14 +46,15 @@ function getTocHeading(panel: Locator, level: number) { return panel.getByTestId(`outline-block-preview-h${level}`).locator('span'); } -async function dragNoteCard(page: Page, fromCard: Locator, toCard: Locator) { - const fromRect = await fromCard.boundingBox(); - const toRect = await toCard.boundingBox(); +// locate cards in outline panel +// ! Please note that when any card mode changed, the locator will be mutated +function locateCards(toc: Locator, mode?: 'both' | 'page' | 'edgeless') { + const cards = toc.locator('affine-outline-note-card'); + return mode ? cards.locator(`[data-visibility="${mode}"]`) : cards; +} - await page.mouse.move(fromRect!.x + 10, fromRect!.y + 10); - await page.mouse.down(); - await page.mouse.move(toRect!.x + 5, toRect!.y + 5, { steps: 20 }); - await page.mouse.up(); +function locateSortingButton(panel: Locator) { + return panel.getByTestId('toggle-notes-sorting-button'); } test.beforeEach(async ({ page }) => { @@ -132,6 +136,18 @@ test('should update panel when modify or clear title or headings', async ({ } }); +test('should update panel when switch doc', async ({ page }) => { + const toc = await openTocPanel(page); + await focusDocTitle(page); + await page.keyboard.press('ArrowDown'); + await type(page, '# Heading 1'); + + await clickNewPageButton(page); + await expect(getTocHeading(toc, 1)).toBeHidden(); + await page.goBack(); + await expect(getTocHeading(toc, 1)).toBeVisible(); +}); + test('should add padding to sub-headings', async ({ page }) => { await createHeadings(page); @@ -226,8 +242,7 @@ test('visibility sorting should be enabled in edgeless mode and disabled in page await type(page, '# Heading 1'); const toc = await openTocPanel(page); - - const sortingButton = toc.getByTestId('toggle-notes-sorting-button'); + const sortingButton = locateSortingButton(toc); await expect(sortingButton).not.toHaveClass(/active/); expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); @@ -240,55 +255,99 @@ test('visibility sorting should be enabled in edgeless mode and disabled in page expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); }); -test('should reorder notes when drag and drop note in outline panel', async ({ - page, -}) => { - await clickEdgelessModeButton(page); - await createEdgelessNoteBlock(page, [100, 100]); - await type(page, 'hello'); - await createEdgelessNoteBlock(page, [200, 200]); - await type(page, 'world'); - - const toc = await openTocPanel(page); - - const docVisibleCards = toc.locator( - 'affine-outline-note-card [data-invisible="false"]' - ); - const docInvisibleCards = toc.locator( - 'affine-outline-note-card [data-invisible="true"]' - ); - - await expect(docVisibleCards).toHaveCount(1); - await expect(docInvisibleCards).toHaveCount(2); - - while ((await docInvisibleCards.count()) > 0) { - const card = docInvisibleCards.first(); +test.describe('drag and drop note in outline panel', () => { + async function changeNoteDisplayMode( + card: Locator, + mode: 'both' | 'doc' | 'edgeless' + ) { await card.hover(); await card.getByTestId('display-mode-button').click(); - await card.locator('note-display-mode-panel').locator('.item.both').click(); + await card.locator(`note-display-mode-panel .item.${mode}`).click(); } - await expect(docVisibleCards).toHaveCount(3); - const noteCard3 = docVisibleCards.nth(2); - const noteCard1 = docVisibleCards.nth(0); + async function dragNoteCard( + page: Page, + fromCard: Locator, + toCard: Locator, + position: 'before' | 'after' = 'before' + ) { + const fromRect = await fromCard.boundingBox(); + const toRect = await toCard.boundingBox(); - await dragNoteCard(page, noteCard3, noteCard1); + await page.mouse.move(fromRect!.x + 10, fromRect!.y + 10); + await page.mouse.down(); + if (position === 'before') { + await page.mouse.move(toRect!.x + 5, toRect!.y + 5, { steps: 20 }); + } else { + await page.mouse.move(toRect!.x + 5, toRect!.y + toRect!.height - 5, { + steps: 20, + }); + } + await page.mouse.up(); + } - await clickPageModeButton(page); - const paragraphs = page - .locator('affine-paragraph') - .locator('[data-v-text="true"]'); - await expect(paragraphs).toHaveCount(3); - await expect(paragraphs.nth(0)).toContainText('world'); - await expect(paragraphs.nth(1)).toContainText(''); - await expect(paragraphs.nth(2)).toContainText('hello'); + // create 2 both cards, 2 page cards and 2 edgeless cards + test.beforeEach(async ({ page }) => { + const toc = await openTocPanel(page); + const edgelessCards = locateCards(toc, 'edgeless'); - // FIXME(@L-Sun): drag and drop is not working in page mode - await dragNoteCard(page, noteCard3, noteCard1); + // 2 both cards + { + await focusDocTitle(page); + await page.keyboard.press('ArrowDown'); + await type(page, '0'); - await expect(paragraphs.nth(0)).toContainText('hello'); - await expect(paragraphs.nth(1)).toContainText('world'); - await expect(paragraphs.nth(2)).toContainText(''); + await clickEdgelessModeButton(page); + + await createEdgelessNoteBlock(page, [100, 100]); + await type(page, '1'); + await changeNoteDisplayMode(edgelessCards.first(), 'both'); + } + // 2 page cards + { + await createEdgelessNoteBlock(page, [150, 150]); + await type(page, '2'); + await changeNoteDisplayMode(edgelessCards.first(), 'doc'); + await createEdgelessNoteBlock(page, [200, 200]); + await type(page, '3'); + await changeNoteDisplayMode(edgelessCards.first(), 'doc'); + } + // 2 edgeless cards + { + await createEdgelessNoteBlock(page, [250, 250]); + await type(page, '4'); + await createEdgelessNoteBlock(page, [300, 300]); + await type(page, '5'); + } + }); + + test('should reorder notes when drag and drop a note in outline panel', async ({ + page, + }) => { + const toc = await openTocPanel(page); + const cards = locateCards(toc); + + await dragNoteCard(page, cards.nth(3), cards.nth(1)); + + await clickPageModeButton(page); + const paragraphs = page + .locator('affine-paragraph') + .locator('[data-v-text="true"]'); + await expect(paragraphs).toHaveCount(4); + await expect(paragraphs.nth(0)).toContainText('0'); + await expect(paragraphs.nth(1)).toContainText('3'); + await expect(paragraphs.nth(2)).toContainText('1'); + await expect(paragraphs.nth(3)).toContainText('2'); + + // Note card should be able to drag and drop in page mode + await locateSortingButton(toc).click(); + await dragNoteCard(page, cards.nth(3), cards.nth(1)); + + await expect(paragraphs.nth(0)).toContainText('0'); + await expect(paragraphs.nth(1)).toContainText('2'); + await expect(paragraphs.nth(2)).toContainText('3'); + await expect(paragraphs.nth(3)).toContainText('1'); + }); }); test.describe('advanced visibility control', () => { @@ -311,15 +370,11 @@ test.describe('advanced visibility control', () => { const toc = await openTocPanel(page); - const docVisibleCard = toc.locator( - 'affine-outline-note-card [data-invisible="false"]' - ); - const docInvisibleCard = toc.locator( - 'affine-outline-note-card [data-invisible="true"]' - ); + const bothCard = locateCards(toc, 'both'); + const edgelessCard = locateCards(toc, 'edgeless'); - await expect(docVisibleCard).toHaveCount(1); - await expect(docInvisibleCard).toHaveCount(1); + await expect(bothCard).toHaveCount(1); + await expect(edgelessCard).toHaveCount(1); await clickView(page, [100, 100]); const noteButtons = locateElementToolbar(page).locator( @@ -329,8 +384,8 @@ test.describe('advanced visibility control', () => { await noteButtons.getByRole('button', { name: 'Mode' }).click(); await noteButtons.locator('note-display-mode-panel .item.both').click(); - await expect(docVisibleCard).toHaveCount(2); - await expect(docInvisibleCard).toHaveCount(0); + await expect(bothCard).toHaveCount(2); + await expect(edgelessCard).toHaveCount(0); }); test('should update notes after slicing note', async ({ page }) => { @@ -342,23 +397,17 @@ test.describe('advanced visibility control', () => { const toc = await openTocPanel(page); - const docVisibleCard = toc.locator( - 'affine-outline-note-card [data-invisible="false"]' - ); - const docInvisibleCard = toc.locator( - 'affine-outline-note-card [data-invisible="true"]' - ); + const bothCard = locateCards(toc, 'both'); + const edgelessCard = locateCards(toc, 'edgeless'); - await expect(docVisibleCard).toHaveCount(1); - await expect(docInvisibleCard).toHaveCount(1); + await expect(bothCard).toHaveCount(1); + await expect(edgelessCard).toHaveCount(1); - await docInvisibleCard.hover(); - await docInvisibleCard.getByTestId('display-mode-button').click(); - await docInvisibleCard - .locator('note-display-mode-panel .item.both') - .click(); + await edgelessCard.hover(); + await edgelessCard.getByTestId('display-mode-button').click(); + await edgelessCard.locator('note-display-mode-panel .item.both').click(); - await expect(docVisibleCard).toHaveCount(2); + await expect(bothCard).toHaveCount(2); await clickView(page, [200, 100]); const changeNoteButtons = locateElementToolbar(page).locator( @@ -368,6 +417,6 @@ test.describe('advanced visibility control', () => { await expect(page.locator('.note-slicer-button')).toBeVisible(); await page.locator('.note-slicer-button').click(); - await expect(docVisibleCard).toHaveCount(3); + await expect(bothCard).toHaveCount(3); }); }); diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts index 27ecd596e..1337a53ef 100644 --- a/tests/kit/src/utils/editor.ts +++ b/tests/kit/src/utils/editor.ts @@ -52,6 +52,14 @@ export function locateEditorContainer(page: Page, editorIndex = 0) { return page.locator('[data-affine-editor-container]').nth(editorIndex); } +export function locateDocTitle(page: Page, editorIndex = 0) { + return locateEditorContainer(page, editorIndex).locator('doc-title'); +} + +export async function focusDocTitle(page: Page, editorIndex = 0) { + await locateDocTitle(page, editorIndex).locator('.inline-editor').focus(); +} + // ================== Page ================== export function locateFormatBar(page: Page, editorIndex = 0) { return locateEditorContainer(page, editorIndex).locator( diff --git a/tests/kit/src/utils/page-logic.ts b/tests/kit/src/utils/page-logic.ts index 8837b956c..879cb5f1f 100644 --- a/tests/kit/src/utils/page-logic.ts +++ b/tests/kit/src/utils/page-logic.ts @@ -199,6 +199,7 @@ export const dragTo = async ( }; // sometimes editor loses focus, this function is to focus the editor +// FIXME: this function is not usable since the placeholder is not unstable export const focusInlineEditor = async (page: Page) => { await page .locator(