From 829980bace69a4284b413cf5b1358417370aafd3 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Fri, 24 Jan 2025 13:27:17 +0000 Subject: [PATCH] refactor(editor): toc dragging with std.dnd (#9883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [BS-2458](https://linear.app/affine-design/issue/BS-2458/toc-dnd重构) ### What Changes - Refactor toc note card dnd with `std.dnd` - Extract note display mode change to command `changeNoteDisplayMode` - It will reorder notes when the display mode changed from `EdgelessOnly` to page mode visible (a.k.a `DocOnly` or `Both`) --- .../src/commands/change-note-display-mode.ts | 61 +++ .../affine/block-note/src/commands/index.ts | 2 + blocksuite/affine/block-note/src/effects.ts | 2 + .../element-toolbar/change-note-button.ts | 31 +- .../components/custom-outline-panel.ts | 5 +- blocksuite/presets/package.json | 1 + .../fragments/outline/body/outline-notice.ts | 4 +- .../outline/body/outline-panel-body.ts | 508 ++++++++---------- .../outline/card/outline-card.css.ts | 4 +- .../fragments/outline/card/outline-card.ts | 98 ++-- .../fragments/outline/card/outline-preview.ts | 8 +- .../src/fragments/outline/utils/drag.ts | 113 +--- .../src/fragments/outline/utils/query.ts | 50 +- blocksuite/tests-legacy/utils/asserts.ts | 2 +- .../blocksuite/outline/outline-panel.spec.ts | 478 ++++++++++------ tests/kit/src/utils/keyboard.ts | 6 + yarn.lock | 3 +- 17 files changed, 689 insertions(+), 687 deletions(-) create mode 100644 blocksuite/affine/block-note/src/commands/change-note-display-mode.ts diff --git a/blocksuite/affine/block-note/src/commands/change-note-display-mode.ts b/blocksuite/affine/block-note/src/commands/change-note-display-mode.ts new file mode 100644 index 000000000..9227e523d --- /dev/null +++ b/blocksuite/affine/block-note/src/commands/change-note-display-mode.ts @@ -0,0 +1,61 @@ +import { NoteDisplayMode } from '@blocksuite/affine-model'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const changeNoteDisplayMode: Command< + never, + never, + { noteId: string; mode: NoteDisplayMode; stopCapture?: boolean } +> = (ctx, next) => { + const { std, noteId, mode, stopCapture } = ctx; + + const noteBlockModel = std.store.getBlock(noteId)?.model; + if (!noteBlockModel || !matchFlavours(noteBlockModel, ['affine:note'])) + return; + + const currentMode = noteBlockModel.displayMode; + if (currentMode === mode) return; + + if (stopCapture) std.store.captureSync(); + + // Update the order in the note list in its parent, for example + // 1. Both mode note | 1. Both mode note + // 2. Page mode note | 2. Page mode note + // ---------------------------- |-> 3. the changed note (Both or Page) + // 3. Edgeless mode note | --------------------------- + // 4. the changing edgeless note -| 4. Edgeless mode note + const parent = std.store.getParent(noteBlockModel); + if (parent) { + const notes = parent.children.filter(child => + matchFlavours(child, ['affine:note']) + ); + const firstEdgelessOnlyNote = notes.find( + note => note.displayMode === NoteDisplayMode.EdgelessOnly + ); + const lastPageVisibleNote = notes.findLast( + note => note.displayMode !== NoteDisplayMode.EdgelessOnly + ); + + if (currentMode === NoteDisplayMode.EdgelessOnly) { + std.store.moveBlocks( + [noteBlockModel], + parent, + lastPageVisibleNote ?? firstEdgelessOnlyNote, + lastPageVisibleNote ? false : true + ); + } else if (mode === NoteDisplayMode.EdgelessOnly) { + std.store.moveBlocks( + [noteBlockModel], + parent, + firstEdgelessOnlyNote ?? lastPageVisibleNote, + firstEdgelessOnlyNote ? true : false + ); + } + } + + std.store.updateBlock(noteBlockModel, { + displayMode: mode, + }); + + return next(); +}; diff --git a/blocksuite/affine/block-note/src/commands/index.ts b/blocksuite/affine/block-note/src/commands/index.ts index c44a700e7..ae18065d0 100644 --- a/blocksuite/affine/block-note/src/commands/index.ts +++ b/blocksuite/affine/block-note/src/commands/index.ts @@ -8,6 +8,7 @@ import { import type { BlockCommands } from '@blocksuite/block-std'; import { updateBlockType } from './block-type.js'; +import { changeNoteDisplayMode } from './change-note-display-mode.js'; import { dedentBlock } from './dedent-block.js'; import { dedentBlockToRoot } from './dedent-block-to-root.js'; import { dedentBlocks } from './dedent-blocks.js'; @@ -37,4 +38,5 @@ export const commands: BlockCommands = { dedentBlocks, dedentBlockToRoot, dedentBlocksToRoot, + changeNoteDisplayMode, }; diff --git a/blocksuite/affine/block-note/src/effects.ts b/blocksuite/affine/block-note/src/effects.ts index 812586d3b..6848e91e2 100644 --- a/blocksuite/affine/block-note/src/effects.ts +++ b/blocksuite/affine/block-note/src/effects.ts @@ -2,6 +2,7 @@ import type { BlockComponent } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; import type { updateBlockType } from './commands/block-type'; +import type { changeNoteDisplayMode } from './commands/change-note-display-mode'; import type { dedentBlock } from './commands/dedent-block'; import type { dedentBlockToRoot } from './commands/dedent-block-to-root'; import type { dedentBlocks } from './commands/dedent-blocks'; @@ -40,6 +41,7 @@ declare global { indentBlock: typeof indentBlock; updateBlockType: typeof updateBlockType; dedentBlockToRoot: typeof dedentBlockToRoot; + changeNoteDisplayMode: typeof changeNoteDisplayMode; } interface CommandContext { focusBlock?: BlockComponent | null; diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts index d47f0a74b..91127d429 100644 --- a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts @@ -195,32 +195,11 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { } private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) { - const { displayMode: currentMode } = note; - if (newMode === currentMode) { - return; - } - - this.doc.captureSync(); - - this.crud.updateElement(note.id, { displayMode: newMode }); - - const noteParent = this.doc.getParent(note); - if (!noteParent) return; - - const noteParentChildNotes = noteParent.children.filter(block => - matchFlavours(block, ['affine:note']) - ); - const noteParentLastNote = - noteParentChildNotes[noteParentChildNotes.length - 1]; - - if ( - currentMode === NoteDisplayMode.EdgelessOnly && - newMode !== NoteDisplayMode.EdgelessOnly && - note !== noteParentLastNote - ) { - // move to the end - this.doc.moveBlocks([note], noteParent, noteParentLastNote, false); - } + this.edgeless.std.command.exec('changeNoteDisplayMode', { + noteId: note.id, + mode: newMode, + stopCapture: true, + }); // if change note to page only, should clear the selection if (newMode === NoteDisplayMode.DocOnly) { diff --git a/blocksuite/playground/apps/_common/components/custom-outline-panel.ts b/blocksuite/playground/apps/_common/components/custom-outline-panel.ts index bdc021c6d..c412913f3 100644 --- a/blocksuite/playground/apps/_common/components/custom-outline-panel.ts +++ b/blocksuite/playground/apps/_common/components/custom-outline-panel.ts @@ -1,10 +1,11 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; import { WithDisposable } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; -import { css, html, LitElement, nothing } from 'lit'; +import { css, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @customElement('custom-outline-panel') -export class CustomOutlinePanel extends WithDisposable(LitElement) { +export class CustomOutlinePanel extends WithDisposable(ShadowlessElement) { static override styles = css` .custom-outline-container { position: absolute; diff --git a/blocksuite/presets/package.json b/blocksuite/presets/package.json index 80da8d40e..b84263248 100644 --- a/blocksuite/presets/package.json +++ b/blocksuite/presets/package.json @@ -12,6 +12,7 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine-block-note": "workspace:^", "@blocksuite/affine-block-surface": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-shared": "workspace:*", diff --git a/blocksuite/presets/src/fragments/outline/body/outline-notice.ts b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts index 2d19aa2c1..e1754efac 100644 --- a/blocksuite/presets/src/fragments/outline/body/outline-notice.ts +++ b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts @@ -48,10 +48,11 @@ export class OutlineNotice extends SignalWatcher( } return html` -
+
SOME CONTENTS HIDDEN { this._visible$.value = false; @@ -64,6 +65,7 @@ export class OutlineNotice extends SignalWatcher( Some contents are not visible on edgeless.
{ this._context.enableSorting$.value = true; 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 3849ae355..43d739b02 100644 --- a/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts +++ b/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts @@ -1,11 +1,14 @@ +import { effects } from '@blocksuite/affine-block-note/effects'; import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std'; import type { NoteBlockModel } from '@blocksuite/blocks'; +import { matchFlavours, NoteDisplayMode } from '@blocksuite/blocks'; import { - BlocksUtils, - matchFlavours, - NoteDisplayMode, -} from '@blocksuite/blocks'; -import { Bound, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; + Bound, + noop, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; import { consume } from '@lit/context'; import { effect, signal } from '@preact/signals-core'; import { html, nothing } from 'lit'; @@ -14,6 +17,8 @@ import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; import { when } from 'lit/directives/when.js'; +noop(effects); + import { type TocContext, tocContext } from '../config'; import type { ClickBlockEvent, @@ -21,7 +26,7 @@ import type { FitViewEvent, SelectEvent, } from '../utils/custom-events'; -import { startDragging } from '../utils/drag'; +import type { NoteCardEntity, NoteDropPayload } from '../utils/drag'; import { getHeadingBlocksFromDoc, getNotesFromDoc, @@ -33,20 +38,6 @@ import { } from '../utils/scroll'; import * as styles from './outline-panel-body.css'; -type OutlineNoteItem = { - note: NoteBlockModel; - - /** - * the index of the note inside its parent's children property - */ - index: number; - - /** - * the number displayed on the outline panel - */ - number: number; -}; - export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body'; export class OutlinePanelBody extends SignalWatcher( @@ -54,24 +45,24 @@ export class OutlinePanelBody extends SignalWatcher( ) { private readonly _activeHeadingId$ = signal(null); - private readonly _insertIndex$ = signal(undefined); - private readonly _dragging$ = signal(false); - private readonly _pageVisibleNoteItems$ = signal([]); + private readonly _indicatorTranslateY$ = signal(0); - private readonly _edgelessOnlyNoteItems$ = signal([]); + private readonly _pageVisibleNotes$ = signal([]); + + private readonly _edgelessOnlyNotes$ = signal([]); + + private readonly _selectedNotes$ = signal([]); private _clearHighlightMask = () => {}; - private _indicatorTranslateY = 0; - private _lockActiveHeadingId = false; private get _shouldRenderEmptyPanel() { return ( - this._pageVisibleNoteItems$.value.length === 0 && - this._edgelessOnlyNoteItems$.value.length === 0 + this._pageVisibleNotes$.value.length === 0 && + this._edgelessOnlyNotes$.value.length === 0 ); } @@ -108,91 +99,7 @@ export class OutlinePanelBody extends SignalWatcher( }); } - /* - * Double click at blank area to disable notes sorting option - */ - 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') || - !enableSorting - ) { - return; - } - - this._context.enableSorting$.value = !enableSorting; - }; - - private _drag() { - const pageVisibleNotes = this._pageVisibleNoteItems$.peek(); - - const selectedVisibleNotes = this._selectedNotes$.peek().filter(id => { - const model = this.doc.getBlock(id)?.model; - return ( - model && - matchFlavours(model, ['affine:note']) && - model.displayMode !== NoteDisplayMode.EdgelessOnly - ); - }); - - if ( - selectedVisibleNotes.length === 0 || - !pageVisibleNotes.length || - !this.doc.root - ) - return; - - if (this.edgeless) { - this.edgeless.service.selection.set({ - elements: selectedVisibleNotes, - editing: false, - }); - } else { - this._selectedNotes$.value = selectedVisibleNotes; - } - - this._dragging$.value = true; - - // cache the notes in case it is changed by other peers - const children = this.doc.root.children.slice() as NoteBlockModel[]; - const notesMap = pageVisibleNotes.reduce((map, note, index) => { - map.set(note.note.id, { - ...note, - number: index + 1, - }); - return map; - }, new Map()); - - startDragging({ - container: this, - document: this.ownerDocument, - host: this.ownerDocument, - doc: this.doc, - outlineListContainer: this._pageVisibleList, - onDragEnd: insertIdx => { - this._dragging$.value = false; - this._insertIndex$.value = undefined; - - if (insertIdx === undefined) return; - - this._moveNotes( - insertIdx, - selectedVisibleNotes, - notesMap, - pageVisibleNotes, - children - ); - }, - onDragMove: (idx, indicatorTranslateY) => { - this._insertIndex$.value = idx; - this._indicatorTranslateY = indicatorTranslateY ?? 0; - }, - }); - } - - private _EmptyPanel() { + private _renderEmptyPanel() { return html`
- BlocksUtils.matchFlavours(block, ['affine:note']) - ) as NoteBlockModel[]; - const noteParentLastNote = - noteParentChildNotes[noteParentChildNotes.length - 1]; - - // When the display mode of a note change from edgeless to page visible - // We should move the note to the end of the note list - if ( - currentMode === NoteDisplayMode.EdgelessOnly && - note !== noteParentLastNote - ) { - this.doc.moveBlocks([note], noteParent, noteParentLastNote, false); - } + this.editor.std.command.exec('changeNoteDisplayMode', { + noteId: note.id, + mode: newMode, + stopCapture: true, + }); // When the display mode of a note changed to page only // We should check if the note is selected in edgeless mode @@ -257,35 +142,200 @@ export class OutlinePanelBody extends SignalWatcher( } } - private _moveNotes( - index: number, - selected: string[], - notesMap: Map, - notes: OutlineNoteItem[], - children: NoteBlockModel[] - ) { - if (!children.length || !this.doc.root) return; + private _moveSelectedNotes(insertIndex: number) { + if (!this.doc.root) return; - const blocks = selected.map( - id => (notesMap.get(id) as OutlineNoteItem).note - ); - const draggingBlocks = new Set(blocks); - const targetIndex = - index === notes.length ? notes[index - 1].index + 1 : notes[index].index; + const pageVisibleNotes = this._pageVisibleNotes$.peek(); + const selected = this._selectedNotes$.peek(); + const children = this.doc.root.children.slice(); + + const noteIndex = new Map(); + children.forEach((block, index) => { + if (matchFlavours(block, ['affine:note'])) { + noteIndex.set(block, index); + } + }); + + let targetIndex: number | null = null; + if (insertIndex === pageVisibleNotes.length) { + const temp = noteIndex.get(pageVisibleNotes[insertIndex - 1]); + if (temp) targetIndex = temp + 1; + } else { + targetIndex = noteIndex.get(pageVisibleNotes[insertIndex]) ?? null; + } + + if (targetIndex === null) return; + + const removeSelectedNoteFilter = (block: BlockModel) => + !matchFlavours(block, ['affine:note']) || !selected.includes(block); const leftPart = children .slice(0, targetIndex) - .filter(block => !draggingBlocks.has(block)); + .filter(removeSelectedNoteFilter); const rightPart = children .slice(targetIndex) - .filter(block => !draggingBlocks.has(block)); - const newChildren = [...leftPart, ...blocks, ...rightPart]; + .filter(removeSelectedNoteFilter); + + const newChildren = [...leftPart, ...selected, ...rightPart]; this.doc.updateBlock(this.doc.root, { children: newChildren, }); } + private async _scrollToBlock(blockId: string) { + this._lockActiveHeadingId = true; + this._activeHeadingId$.value = blockId; + this._clearHighlightMask = await scrollToBlockWithHighlight( + this.editor, + blockId + ); + this._lockActiveHeadingId = false; + } + + private _selectNote(e: SelectEvent) { + const { selected, id, multiselect } = e.detail; + const note = this.doc.getBlock(id)?.model; + if (!note || !matchFlavours(note, ['affine:note'])) return; + + let selectedNotes = this._selectedNotes$.peek(); + + if (!selected) { + selectedNotes = selectedNotes.filter(_note => _note !== note); + } else if (multiselect) { + selectedNotes = [...selectedNotes, note]; + } else { + selectedNotes = [note]; + } + + if (this.edgeless) { + this.edgeless?.service.selection.set({ + elements: selectedNotes.map(({ id }) => id), + editing: false, + }); + } else { + this._selectedNotes$.value = selectedNotes; + } + } + + private _watchSelectedNotes() { + return effect(() => { + const { std, doc, mode } = this.editor; + if (mode !== 'edgeless') return; + + const currSelectedNotes = std.selection + .filter(SurfaceSelection) + .map(({ blockId }) => doc.getBlock(blockId)?.model) + .filter(model => { + return !!model && matchFlavours(model, ['affine:note']); + }); + + const preSelected = this._selectedNotes$.peek(); + if ( + preSelected.length !== currSelectedNotes.length || + preSelected.some(note => !currSelectedNotes.includes(note)) + ) { + this._selectedNotes$.value = currSelectedNotes; + } + }); + } + + private _watchNotes() { + this.disposables.add( + effect(() => { + const isRenderableNote = (note: NoteBlockModel) => { + let hasHeadings = false; + + for (const block of note.children) { + if (isHeadingBlock(block)) { + hasHeadings = true; + break; + } + } + return hasHeadings || this._context.enableSorting$.value; + }; + + this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [ + NoteDisplayMode.DocAndEdgeless, + NoteDisplayMode.DocOnly, + ]).filter(isRenderableNote); + + this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [ + NoteDisplayMode.EdgelessOnly, + ]).filter(isRenderableNote); + }) + ); + } + + private _watchDragAndDrop() { + const std = this.editor.std; + this.disposables.add( + std.dnd.monitor({ + onDragStart: () => { + this._dragging$.value = true; + this._selectedNotes$.value = this._selectedNotes$ + .peek() + .filter(note => { + return this._pageVisibleNotes$.value.includes(note); + }); + }, + onDrag: data => { + const target = data.location.current.dropTargets[0]; + if (!target) return; + const edge = target.data.edge; + const rect = target.element.getBoundingClientRect(); + const parentRect = this._pageVisibleList.getBoundingClientRect(); + this._indicatorTranslateY$.value = + edge === 'top' + ? rect.top - parentRect.top + : rect.bottom - parentRect.top; + }, + onDrop: data => { + this._dragging$.value = false; + const target = data.location.current.dropTargets[0]; + if (!target) return; + + const edge = target.data.edge; + const index = this._pageVisibleNotes$ + .peek() + .findIndex(({ id }) => id === target.data.noteId); + + if (index === -1) return; + + this._moveSelectedNotes(edge === 'top' ? index : index + 1); + }, + }) + ); + this.disposables.add( + std.dnd.autoScroll({ + element: this, + }) + ); + } + + override connectedCallback(): void { + super.connectedCallback(); + this.classList.add(styles.outlinePanelBody); + + this.disposables.add( + observeActiveHeadingDuringScroll( + () => this.editor, + newHeadingId => { + if (this._lockActiveHeadingId) return; + this._activeHeadingId$.value = newHeadingId; + } + ) + ); + this._watchNotes(); + this._watchSelectedNotes(); + this._watchDragAndDrop(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._clearHighlightMask(); + } + private _renderDocTitle() { if (!this.doc.root) return nothing; @@ -304,33 +354,30 @@ export class OutlinePanelBody extends SignalWatcher( return html` { this._scrollToBlock(rootId).catch(console.error); }} >`; } - private _renderNoteCards(items: OutlineNoteItem[]) { + private _renderNoteCards(notes: NoteBlockModel[]) { return repeat( - items, - item => item.note.id, - (item, idx) => + notes, + ({ id }) => id, + (note, index) => html` { this._scrollToBlock(e.detail.blockId).catch(console.error); }} @@ -341,19 +388,19 @@ export class OutlinePanelBody extends SignalWatcher( private _renderPageVisibleCardList() { return html`
${when( - this._insertIndex$.value !== undefined, + this._dragging$.value, () => html`
` )} - ${this._renderNoteCards(this._pageVisibleNoteItems$.value)} + ${this._renderNoteCards(this._pageVisibleNotes$.value)}
`; } private _renderEdgelessOnlyCardList() { - const items = this._edgelessOnlyNoteItems$.value; + const items = this._edgelessOnlyNotes$.value; return html`
${when( items.length > 0, @@ -364,125 +411,12 @@ export class OutlinePanelBody extends SignalWatcher(
`; } - private async _scrollToBlock(blockId: string) { - this._lockActiveHeadingId = true; - this._activeHeadingId$.value = blockId; - this._clearHighlightMask = await scrollToBlockWithHighlight( - this.editor, - blockId - ); - this._lockActiveHeadingId = false; - } - - private readonly _selectedNotes$ = signal([]); - - private _selectNote(e: SelectEvent) { - const { selected, id, multiselect } = e.detail; - - let selectedNotes = this._selectedNotes$.peek(); - - if (!selected) { - selectedNotes = selectedNotes.filter(noteId => noteId !== id); - } else if (multiselect) { - selectedNotes = [...selectedNotes, id]; - } else { - selectedNotes = [id]; - } - - if (this.edgeless) { - this.edgeless?.service.selection.set({ - elements: selectedNotes, - editing: false, - }); - } else { - this._selectedNotes$.value = selectedNotes; - } - } - - 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(() => { - 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._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); - }) - ); - } - - override connectedCallback(): void { - super.connectedCallback(); - this.classList.add(styles.outlinePanelBody); - - this.disposables.add( - observeActiveHeadingDuringScroll( - () => this.editor, - newHeadingId => { - if (this._lockActiveHeadingId) return; - this._activeHeadingId$.value = newHeadingId; - } - ) - ); - this._watchNotes(); - this._watchSelectedNotes(); - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this._clearHighlightMask(); - } - - override firstUpdated(): void { - this.disposables.addFromEvent(this, 'dblclick', this._doubleClickHandler); - } - override render() { return html` ${this._renderDocTitle()} ${when( this._shouldRenderEmptyPanel, - () => this._EmptyPanel(), + () => this._renderEmptyPanel(), () => html` ${this._renderPageVisibleCardList()} ${this._renderEdgelessOnlyCardList()} 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 38e0999a5..a96b48f20 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.css.ts @@ -10,7 +10,7 @@ export const outlineCard = style({ boxSizing: 'border-box', selectors: { - '&[data-status="placeholder"]': { + '&[data-status="dragging"]': { pointerEvents: 'none', opacity: 0.5, }, @@ -33,7 +33,7 @@ export const cardPreview = style({ [`${outlineCard}[data-status="selected"] &`]: { background: cssVarV2('layer/background/hoverOverlay'), }, - [`${outlineCard}[data-status="placeholder"] &`]: { + [`${outlineCard}[data-status="dragging"] &`]: { background: cssVarV2('layer/background/hoverOverlay'), opacity: 0.9, }, diff --git a/blocksuite/presets/src/fragments/outline/card/outline-card.ts b/blocksuite/presets/src/fragments/outline/card/outline-card.ts index c53459847..f10c825c4 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-card.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.ts @@ -3,8 +3,6 @@ import { createButtonPopper, type NoteBlockModel, NoteDisplayMode, - on, - once, } from '@blocksuite/blocks'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit'; @@ -17,6 +15,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { type TocContext, tocContext } from '../config'; import type { SelectEvent } from '../utils/custom-events'; +import type { NoteCardEntity, NoteDropPayload } from '../utils/drag'; import * as styles from './outline-card.css'; export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card'; @@ -39,13 +38,10 @@ export class OutlineNoteCard extends SignalWatcher( this.dispatchEvent(event); } - private _dispatchDisplayModeChangeEvent( - note: NoteBlockModel, - newMode: NoteDisplayMode - ) { + private _dispatchDisplayModeChangeEvent(newMode: NoteDisplayMode) { const event = new CustomEvent('displaymodechange', { detail: { - note, + note: this.note, newMode, }, }); @@ -53,33 +49,6 @@ export class OutlineNoteCard extends SignalWatcher( this.dispatchEvent(event); } - private _dispatchDragEvent(e: MouseEvent) { - e.preventDefault(); - if (e.button !== 0 || !this._context.enableSorting$.peek()) return; - - const { clientX: startX, clientY: startY } = e; - const disposeDragStart = on(this.ownerDocument, 'mousemove', e => { - if ( - Math.abs(startX - e.clientX) < 5 && - Math.abs(startY - e.clientY) < 5 - ) { - return; - } - if (this.status !== 'selected') { - this._dispatchSelectEvent(e); - } - - const event = new CustomEvent('drag'); - - this.dispatchEvent(event); - disposeDragStart(); - }); - - once(this.ownerDocument, 'mouseup', () => { - disposeDragStart(); - }); - } - private _dispatchFitViewEvent(e: MouseEvent) { e.stopPropagation(); @@ -98,7 +67,6 @@ export class OutlineNoteCard extends SignalWatcher( detail: { id: this.note.id, selected: this.status !== 'selected', - number: this.number, multiselect: e.shiftKey, }, }) as SelectEvent; @@ -119,11 +87,48 @@ export class OutlineNoteCard extends SignalWatcher( } } - get invisible() { - return this.note.displayMode === NoteDisplayMode.EdgelessOnly; + private _watchDragEvents() { + const std = this._context.editor$.value.std; + this.disposables.add( + std.dnd.draggable({ + element: this, + canDrag: () => this.note.displayMode !== NoteDisplayMode.EdgelessOnly, + onDragStart: () => { + if (this.status !== 'selected') { + this.dispatchEvent( + new CustomEvent('select', { + detail: { + id: this.note.id, + selected: true, + multiselect: false, + }, + }) + ); + } + }, + setDragData: () => ({ + type: 'toc-card', + noteId: this.note.id, + }), + }) + ); + + this.disposables.add( + std.dnd.dropTarget({ + element: this, + setDropData: () => ({ + noteId: this.note.id, + }), + }) + ); } - override updated() { + override connectedCallback() { + super.connectedCallback(); + this._watchDragEvents(); + } + + override firstUpdated() { this._displayModePopper = createButtonPopper( this._displayModeButtonGroup, this._displayModePanel, @@ -156,7 +161,6 @@ export class OutlineNoteCard extends SignalWatcher( >
@@ -166,7 +170,9 @@ export class OutlineNoteCard extends SignalWatcher( ? html`${InvisibleIcon({ width: '20px', height: '20px' })}` - : html`${this.number}` + : html`${this.index + 1}` }
@@ -194,7 +200,7 @@ export class OutlineNoteCard extends SignalWatcher( .displayMode=${displayMode} .panelWidth=${220} .onSelect=${(newMode: NoteDisplayMode) => { - this._dispatchDisplayModeChangeEvent(this.note, newMode); + this._dispatchDisplayModeChangeEvent(newMode); this._displayModePopper?.hide(); }} > @@ -206,7 +212,6 @@ export class OutlineNoteCard extends SignalWatcher( class=${classMap({ active: this.activeHeadingId === block.id })} .block=${block} .disabledIcon=${invisible} - .cardNumber=${this.number} @click=${() => { if (invisible) return; this._dispatchClickBlockEvent(block); @@ -229,17 +234,14 @@ export class OutlineNoteCard extends SignalWatcher( @property({ attribute: false }) accessor activeHeadingId: string | null = null; - @property({ attribute: false }) - accessor index!: number; - @property({ attribute: false }) accessor note!: NoteBlockModel; - @property({ attribute: false }) - accessor number!: number; + @property({ attribute: true, type: Number }) + accessor index: number = 0; @property({ attribute: false }) - accessor status: 'selected' | 'placeholder' | 'normal' = 'normal'; + accessor status: 'selected' | 'dragging' | 'normal' = 'normal'; @consume({ context: tocContext }) private accessor _context!: TocContext; diff --git a/blocksuite/presets/src/fragments/outline/card/outline-preview.ts b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts index 32c86dc4d..4941336f9 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-preview.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts @@ -14,6 +14,7 @@ import { import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { LinkedPageIcon } from '@blocksuite/icons/lit'; import type { DeltaInsert } from '@blocksuite/inline'; +import type { BlockModel } from '@blocksuite/store'; import { consume } from '@lit/context'; import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; @@ -28,8 +29,6 @@ import { import { isHeadingBlock, isRootBlock } from '../utils/query.js'; import * as styles from './outline-preview.css'; -type ValuesOf = T[K]; - function assertType(value: unknown): asserts value is T { noop(value); } @@ -206,10 +205,7 @@ export class OutlineBlockPreview extends SignalWatcher( } @property({ attribute: false }) - accessor block!: ValuesOf; - - @property({ attribute: false }) - accessor cardNumber!: number; + accessor block!: BlockModel; @property({ attribute: false }) accessor disabledIcon = false; diff --git a/blocksuite/presets/src/fragments/outline/utils/drag.ts b/blocksuite/presets/src/fragments/outline/utils/drag.ts index 642aec906..42ea2c862 100644 --- a/blocksuite/presets/src/fragments/outline/utils/drag.ts +++ b/blocksuite/presets/src/fragments/outline/utils/drag.ts @@ -1,107 +1,8 @@ -import { NoteDisplayMode, on, once } from '@blocksuite/blocks'; -import type { Store } from '@blocksuite/store'; +export type NoteCardEntity = { + type: 'toc-card'; + noteId: string; +}; -import type { OutlinePanelBody } from '../body/outline-panel-body.js'; -import type { OutlineNoteCard } from '../card/outline-card.js'; - -/** - * start drag notes - * @param notes notes to drag - */ -export function startDragging(options: { - onDragEnd?: (insertIndex?: number) => void; - onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void; - outlineListContainer: HTMLElement; - document: Document; - host: Document | HTMLElement; - container: OutlinePanelBody; - doc: Store; -}) { - const { - document, - host, - container, - onDragMove, - onDragEnd, - outlineListContainer, - } = options; - const maskElement = createMaskElement(document); - const listContainerRect = outlineListContainer.getBoundingClientRect(); - const children = Array.from( - outlineListContainer.children - ) as OutlineNoteCard[]; - let idx: undefined | number; - let indicatorTranslateY: undefined | number; - - container.renderRoot.append(maskElement); - - const insideListContainer = (e: MouseEvent) => { - return ( - e.clientX >= listContainerRect.left && - e.clientX <= listContainerRect.right && - e.clientY >= listContainerRect.top && - e.clientY <= listContainerRect.bottom - ); - }; - - const disposeMove = on(container, 'mousemove', e => { - if (!insideListContainer(e)) { - idx = undefined; - onDragMove?.(idx, 0); - return; - } - - idx = 0; - for (const card of children) { - if (!card.note || card.note.displayMode === NoteDisplayMode.EdgelessOnly) - break; - - const topBoundary = - 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; - - indicatorTranslateY = - e.clientY > midBoundary ? bottomBoundary : topBoundary; - indicatorTranslateY -= listContainerRect.top; - - onDragMove?.(idx, indicatorTranslateY); - return; - } - - ++idx; - } - - onDragMove?.(idx); - }); - - let ended = false; - const dragEnd = () => { - if (ended) return; - - ended = true; - maskElement.remove(); - - disposeMove(); - onDragEnd?.(idx); - }; - - once(host as Document, 'mouseup', dragEnd); -} - -function createMaskElement(doc: Document) { - const mask = doc.createElement('div'); - - mask.style.height = '100vh'; - mask.style.width = '100vw'; - mask.style.position = 'fixed'; - mask.style.left = '0'; - mask.style.top = '0'; - mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)'; - mask.style.cursor = 'grabbing'; - - return mask; -} +export type NoteDropPayload = { + noteId: string; +}; diff --git a/blocksuite/presets/src/fragments/outline/utils/query.ts b/blocksuite/presets/src/fragments/outline/utils/query.ts index a65c246c8..4b7cb3665 100644 --- a/blocksuite/presets/src/fragments/outline/utils/query.ts +++ b/blocksuite/presets/src/fragments/outline/utils/query.ts @@ -1,7 +1,8 @@ import { BlocksUtils, + matchFlavours, type NoteBlockModel, - type NoteDisplayMode, + NoteDisplayMode, type ParagraphBlockModel, type RootBlockModel, } from '@blocksuite/blocks'; @@ -9,39 +10,24 @@ import type { BlockModel, Store } from '@blocksuite/store'; import { headingKeys } from '../config.js'; -type OutlineNoteItem = { - note: NoteBlockModel; - /** - * the index of the note inside its parent's children property - */ - index: number; - /** - * the number displayed on the outline panel - */ - number: number; -}; - export function getNotesFromDoc( doc: Store, - modes: NoteDisplayMode[] -): OutlineNoteItem[] { + modes: NoteDisplayMode[] = [ + NoteDisplayMode.DocAndEdgeless, + NoteDisplayMode.DocOnly, + NoteDisplayMode.EdgelessOnly, + ] +) { const rootModel = doc.root; if (!rootModel) return []; - const notes: OutlineNoteItem[] = []; + const notes: NoteBlockModel[] = []; - rootModel.children.forEach((block, index) => { - if (!['affine:note'].includes(block.flavour)) return; + rootModel.children.forEach(block => { + if (!matchFlavours(block, ['affine:note'])) return; - const blockModel = block as NoteBlockModel; - const OutlineNoteItem = { - note: block as NoteBlockModel, - index, - number: index + 1, - }; - - if (modes.includes(blockModel.displayMode$.value)) { - notes.push(OutlineNoteItem); + if (modes.includes(block.displayMode$.value)) { + notes.push(block); } }); @@ -75,11 +61,13 @@ export function getHeadingBlocksFromNote( export function getHeadingBlocksFromDoc( doc: Store, - modes: NoteDisplayMode[], + modes: NoteDisplayMode[] = [ + NoteDisplayMode.DocAndEdgeless, + NoteDisplayMode.DocOnly, + NoteDisplayMode.EdgelessOnly, + ], ignoreEmpty = false ) { const notes = getNotesFromDoc(doc, modes); - return notes - .map(({ note }) => getHeadingBlocksFromNote(note, ignoreEmpty)) - .flat(); + return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat(); } diff --git a/blocksuite/tests-legacy/utils/asserts.ts b/blocksuite/tests-legacy/utils/asserts.ts index 0a87501eb..bfb7a86f3 100644 --- a/blocksuite/tests-legacy/utils/asserts.ts +++ b/blocksuite/tests-legacy/utils/asserts.ts @@ -302,7 +302,7 @@ export async function assertVisibleBlockCount( // not only count, but also check if all the blocks are visible const locator = page.locator(`affine-${flavour}`); let visibleCount = 0; - for (let i = 0; i < count; i++) { + for (let i = 0; i < (await locator.count()); i++) { if (await locator.nth(i).isVisible()) { visibleCount++; } 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 881af46ab..11ced364e 100644 --- a/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts +++ b/tests/affine-local/e2e/blocksuite/outline/outline-panel.spec.ts @@ -5,11 +5,13 @@ import { clickView, createEdgelessNoteBlock, focusDocTitle, + getEdgelessSelectedIds, locateElementToolbar, } from '@affine-test/kit/utils/editor'; import { pressBackspace, pressEnter, + pressEscape, selectAllByKeyboard, } from '@affine-test/kit/utils/keyboard'; import { openHomePage } from '@affine-test/kit/utils/load-page'; @@ -50,241 +52,342 @@ function getTocHeading(panel: Locator, level: number) { // ! 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; + return mode + ? cards.locator(`>div[data-visibility="${mode}"]`) + : cards.locator('>div'); } function locateSortingButton(panel: Locator) { return panel.getByTestId('toggle-notes-sorting-button'); } +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 .item.${mode}`).click(); +} + test.beforeEach(async ({ page }) => { await openHomePage(page); await clickNewPageButton(page); await waitForEditorLoad(page); }); -test('should display title and headings when there are non-empty headings in editor', async ({ - page, -}) => { - await createTitle(page); - await createHeadings(page); +test.describe('TOC display', () => { + test('should display title and headings when there are non-empty headings in editor', async ({ + page, + }) => { + await createTitle(page); + await createHeadings(page); - const toc = await openTocPanel(page); + const toc = await openTocPanel(page); - await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible(); - for (let i = 1; i <= 6; i++) { - await expect(getTocHeading(toc, i)).toBeVisible(); - await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`); - } -}); + await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible(); + for (let i = 1; i <= 6; i++) { + await expect(getTocHeading(toc, i)).toBeVisible(); + await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`); + } + }); -test('should display placeholder when no headings', async ({ page }) => { - const toc = await openTocPanel(page); - const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder'); + test('should display placeholder when no headings', async ({ page }) => { + const toc = await openTocPanel(page); + const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder'); - await createTitle(page); - await pressEnter(page); - await type(page, 'hello world'); - - await expect(noHeadingPlaceholder).toBeVisible(); -}); - -test('should not display headings when there are only empty headings', async ({ - page, -}) => { - await createTitle(page); - - // create empty headings - for (let i = 1; i <= 6; i++) { - await type(page, `${'#'.repeat(i)} `); + await createTitle(page); await pressEnter(page); - } + await type(page, 'hello world'); - const toc = await openTocPanel(page); + await expect(noHeadingPlaceholder).toBeVisible(); + }); - await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden(); - for (let i = 1; i <= 6; i++) { - await expect(getTocHeading(toc, i)).toBeHidden(); - } -}); + test('should not display headings when there are only empty headings', async ({ + page, + }) => { + await createTitle(page); -test('should update panel when modify or clear title or headings', async ({ - page, -}) => { - const title = await createTitle(page); - const headings = await createHeadings(page); + // create empty headings + for (let i = 1; i <= 6; i++) { + await type(page, `${'#'.repeat(i)} `); + await pressEnter(page); + } - const toc = await openTocPanel(page); + const toc = await openTocPanel(page); - await title.scrollIntoViewIfNeeded(); - await title.click(); - await type(page, 'xxx'); - await expect(toc.getByTestId('outline-block-preview-title')).toContainText([ - 'Titlexxx', - ]); - await selectAllByKeyboard(page); - await pressBackspace(page); - await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden(); + await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden(); + for (let i = 1; i <= 6; i++) { + await expect(getTocHeading(toc, i)).toBeHidden(); + } + }); - for (let i = 1; i <= 6; i++) { - await headings[i - 1].click(); + test('should update panel when modify or clear title or headings', async ({ + page, + }) => { + const title = await createTitle(page); + const headings = await createHeadings(page); + + const toc = await openTocPanel(page); + + await title.scrollIntoViewIfNeeded(); + await title.click(); await type(page, 'xxx'); - await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}xxx`); + await expect(toc.getByTestId('outline-block-preview-title')).toContainText([ + 'Titlexxx', + ]); await selectAllByKeyboard(page); await pressBackspace(page); - await expect(getTocHeading(toc, i)).toBeHidden(); - } -}); + await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden(); -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'); + for (let i = 1; i <= 6; i++) { + await headings[i - 1].click(); + await type(page, 'xxx'); + await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}xxx`); + await selectAllByKeyboard(page); + await pressBackspace(page); + await expect(getTocHeading(toc, i)).toBeHidden(); + } + }); - await clickNewPageButton(page); - await expect(getTocHeading(toc, 1)).toBeHidden(); - await page.goBack(); - await expect(getTocHeading(toc, 1)).toBeVisible(); -}); + 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'); -test('should add padding to sub-headings', async ({ page }) => { - await createHeadings(page); + await clickNewPageButton(page); + await expect(getTocHeading(toc, 1)).toBeHidden(); + await page.goBack(); + await expect(getTocHeading(toc, 1)).toBeVisible(); + }); - const toc = await openTocPanel(page); + test('should add padding to sub-headings', async ({ page }) => { + await createHeadings(page); - let prev = getTocHeading(toc, 1); - for (let i = 2; i <= 6; i++) { - const curr = getTocHeading(toc, i); + const toc = await openTocPanel(page); - const prevRect = await prev.boundingBox(); - const currRect = await curr.boundingBox(); + let prev = getTocHeading(toc, 1); + for (let i = 2; i <= 6; i++) { + const curr = getTocHeading(toc, i); - expect(prevRect).not.toBeNull(); - expect(currRect).not.toBeNull(); + const prevRect = await prev.boundingBox(); + const currRect = await curr.boundingBox(); - expect(prevRect!.x).toBeLessThan(currRect!.x); - prev = curr; - } -}); + expect(prevRect).not.toBeNull(); + expect(currRect).not.toBeNull(); -test('should highlight heading when scroll to area before viewport center', async ({ - page, -}) => { - const title = await createTitle(page); - for (let i = 0; i < 3; i++) { + expect(prevRect!.x).toBeLessThan(currRect!.x); + prev = curr; + } + }); + + test('visibility sorting should be enabled in edgeless mode and disabled in page mode by default, and can be changed', async ({ + page, + }) => { await pressEnter(page); - } - const headings = await createHeadings(page, 10); - await title.scrollIntoViewIfNeeded(); + await type(page, '# Heading 1'); - const toc = await openTocPanel(page); + const toc = await openTocPanel(page); + const sortingButton = locateSortingButton(toc); + await expect(sortingButton).not.toHaveClass(/active/); + expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); - const viewportCenter = await getVerticalCenterFromLocator( - page.locator('body') - ); + await clickEdgelessModeButton(page); + await expect(sortingButton).toHaveClass(/active/); + expect(toc.locator('[data-sortable="true"]')).toHaveCount(1); - const activeHeadingContainer = toc.locator( - 'affine-outline-panel-body .active' - ); + await sortingButton.click(); + await expect(sortingButton).not.toHaveClass(/active/); + expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); + }); - await title.click(); - await expect(activeHeadingContainer).toContainText('Title'); + test('should notify user when there are some page only notes and sorting is disabled', async ({ + page, + }) => { + await clickEdgelessModeButton(page); + const toc = await openTocPanel(page); + await createEdgelessNoteBlock(page, [100, 100]); + const card = locateCards(toc, 'edgeless'); + await changeNoteDisplayMode(card, 'doc'); - for (let i = 0; i < headings.length; i++) { - const lastHeadingCenter = await getVerticalCenterFromLocator(headings[i]); - await page.mouse.wheel(0, lastHeadingCenter - viewportCenter + 20); - await page.waitForTimeout(10); + const notification = toc.getByTestId('affine-outline-notice'); + await expect(notification).toBeHidden(); - await expect(activeHeadingContainer).toContainText(`Heading ${i + 1}`); - } + const sortingButton = locateSortingButton(toc); + await sortingButton.click(); + await expect(notification).toBeVisible(); + + await notification.getByTestId('outline-notice-sort-button').click(); + await expect(notification).toBeHidden(); + await expect(sortingButton).toHaveClass(/active/); + + await sortingButton.click(); + await expect(notification).toBeVisible(); + await notification.getByTestId('outline-notice-close-button').click(); + await expect(notification).toBeHidden(); + }); }); -test('should scroll to heading and highlight heading when click item in outline panel', async ({ - page, -}) => { - const headings = await createHeadings(page, 10); - const toc = await openTocPanel(page); +test.describe('TOC and editor scroll', () => { + test('should highlight heading when scroll to area before viewport center', async ({ + page, + }) => { + const title = await createTitle(page); + for (let i = 0; i < 3; i++) { + await pressEnter(page); + } + const headings = await createHeadings(page, 10); + await title.scrollIntoViewIfNeeded(); - const activeHeadingContainer = toc.locator( - 'affine-outline-panel-body .active' - ); + const toc = await openTocPanel(page); - const headingsInPanel = Array.from({ length: 6 }, (_, i) => - getTocHeading(toc, i + 1) - ); + const viewportCenter = await getVerticalCenterFromLocator( + page.locator('body') + ); - await headingsInPanel[2].click(); - await expect(headings[2]).toBeVisible(); - await expect(activeHeadingContainer).toContainText('Heading 3'); + const activeHeadingContainer = toc.locator( + 'affine-outline-panel-body .active' + ); + + await title.click(); + await expect(activeHeadingContainer).toContainText('Title'); + + for (let i = 0; i < headings.length; i++) { + const lastHeadingCenter = await getVerticalCenterFromLocator(headings[i]); + await page.mouse.wheel(0, lastHeadingCenter - viewportCenter + 20); + await page.waitForTimeout(10); + + await expect(activeHeadingContainer).toContainText(`Heading ${i + 1}`); + } + }); + + test('should scroll to heading and highlight heading when click item in outline panel', async ({ + page, + }) => { + const headings = await createHeadings(page, 10); + const toc = await openTocPanel(page); + + const activeHeadingContainer = toc.locator( + 'affine-outline-panel-body .active' + ); + + const headingsInPanel = Array.from({ length: 6 }, (_, i) => + getTocHeading(toc, i + 1) + ); + + await headingsInPanel[2].click(); + await expect(headings[2]).toBeVisible(); + await expect(activeHeadingContainer).toContainText('Heading 3'); + }); + + test('should scroll to title when click title in outline panel', async ({ + page, + }) => { + const title = await createTitle(page); + await pressEnter(page); + await createHeadings(page, 10); + + const toc = await openTocPanel(page); + + const titleInPanel = toc.getByTestId('outline-block-preview-title'); + + await expect(title).not.toBeInViewport(); + await titleInPanel.click(); + await expect(title).toBeVisible(); + }); }); -test('should scroll to title when click title in outline panel', async ({ - page, -}) => { - const title = await createTitle(page); - await pressEnter(page); - await createHeadings(page, 10); +test.describe('TOC and edgeless selection', () => { + test('should select note blocks when selecting cards in TOC', async ({ + page, + }) => { + const toc = await openTocPanel(page); + await clickEdgelessModeButton(page); - const toc = await openTocPanel(page); + const cards = locateCards(toc); - const titleInPanel = toc.getByTestId('outline-block-preview-title'); + await createEdgelessNoteBlock(page, [100, 100]); + await changeNoteDisplayMode(cards.last(), 'doc'); + await createEdgelessNoteBlock(page, [200, 200]); - await expect(title).not.toBeInViewport(); - await titleInPanel.click(); - await expect(title).toBeVisible(); -}); + await cards.nth(0).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(1); + await cards.nth(0).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(0); -test('visibility sorting should be enabled in edgeless mode and disabled in page mode by default, and can be changed', async ({ - page, -}) => { - await pressEnter(page); - await type(page, '# Heading 1'); + await cards.nth(1).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(1); + await cards.nth(1).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(0); - const toc = await openTocPanel(page); - const sortingButton = locateSortingButton(toc); - await expect(sortingButton).not.toHaveClass(/active/); - expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); + await cards.nth(2).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(1); + await cards.nth(2).click(); + expect(await getEdgelessSelectedIds(page)).toHaveLength(0); - await clickEdgelessModeButton(page); - await expect(sortingButton).toHaveClass(/active/); - expect(toc.locator('[data-sortable="true"]')).toHaveCount(1); + await cards.nth(0).click({ modifiers: ['Shift'] }); + await cards.nth(1).click({ modifiers: ['Shift'] }); + await cards.nth(2).click({ modifiers: ['Shift'] }); - await sortingButton.click(); - await expect(sortingButton).not.toHaveClass(/active/); - expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); + expect(await getEdgelessSelectedIds(page)).toHaveLength(3); + }); + + test('should select note cards when select note blocks in canvas', async ({ + page, + }) => { + await clickEdgelessModeButton(page); + await selectAllByKeyboard(page); + await pressBackspace(page); + await createEdgelessNoteBlock(page, [100, 100]); + await createEdgelessNoteBlock(page, [200, 200]); + await clickView(page, [0, 0]); + + const toc = await openTocPanel(page); + const cards = locateCards(toc); + + await clickView(page, [100, 100]); + await expect(cards.nth(0)).toHaveAttribute('data-status', 'selected'); + + await clickView(page, [200, 200]); + await expect(cards.nth(1)).toHaveAttribute('data-status', 'selected'); + + await selectAllByKeyboard(page); + await expect(cards.nth(0)).toHaveAttribute('data-status', 'selected'); + await expect(cards.nth(1)).toHaveAttribute('data-status', 'selected'); + + await pressEscape(page); + await expect(cards.nth(0)).toHaveAttribute('data-status', 'normal'); + await expect(cards.nth(1)).toHaveAttribute('data-status', 'normal'); + }); }); 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 .item.${mode}`).click(); - } - - async function dragNoteCard( - page: Page, - fromCard: Locator, - toCard: Locator, + const dragCard = async ( + from: Locator, + to: Locator, position: 'before' | 'after' = 'before' - ) { - const fromRect = await fromCard.boundingBox(); - const toRect = await toCard.boundingBox(); + ) => { + const fromRect = await from.boundingBox(); + const fromCenter = { + x: fromRect!.width / 2, + y: fromRect!.height / 2, + }; - 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(); - } + const toRect = await to.boundingBox(); + const toCenter = { + x: toRect!.width / 2, + y: toRect!.height / 2, + }; + + await from.dragTo(to, { + sourcePosition: fromCenter, + targetPosition: + position === 'before' + ? { x: toCenter.x, y: toCenter.y - 10 } + : { x: toCenter.x, y: toCenter.y + 10 }, + }); + }; // create 2 both cards, 2 page cards and 2 edgeless cards test.beforeEach(async ({ page }) => { @@ -327,7 +430,7 @@ test.describe('drag and drop note in outline panel', () => { const toc = await openTocPanel(page); const cards = locateCards(toc); - await dragNoteCard(page, cards.nth(3), cards.nth(1)); + await dragCard(cards.nth(3), cards.nth(1)); await clickPageModeButton(page); const paragraphs = page @@ -341,13 +444,36 @@ test.describe('drag and drop note in outline panel', () => { // 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 dragCard(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('multiple selected note cards can be dragged and dropped at same time', async ({ + page, + }) => { + const toc = await openTocPanel(page); + const cards = locateCards(toc); + + await cards.nth(2).click({ modifiers: ['Shift'] }); + await cards.nth(3).click({ modifiers: ['Shift'] }); + + await dragCard(cards.nth(2), cards.nth(0)); + 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('2'); + await expect(paragraphs.nth(1)).toContainText('3'); + await expect(paragraphs.nth(2)).toContainText('0'); + await expect(paragraphs.nth(3)).toContainText('1'); + }); }); test.describe('advanced visibility control', () => { diff --git a/tests/kit/src/utils/keyboard.ts b/tests/kit/src/utils/keyboard.ts index 3ed65c780..cb472c2d2 100644 --- a/tests/kit/src/utils/keyboard.ts +++ b/tests/kit/src/utils/keyboard.ts @@ -54,6 +54,12 @@ export async function pressBackspace(page: Page, count = 1) { } } +export async function pressEscape(page: Page, count = 1) { + for (let i = 0; i < count; i++) { + await page.keyboard.press('Escape', { delay: 50 }); + } +} + export async function copyByKeyboard(page: Page) { await keyDownCtrlOrMeta(page); await page.keyboard.press('c', { delay: 50 }); diff --git a/yarn.lock b/yarn.lock index 99a73f3a4..8fbfd5b6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3551,7 +3551,7 @@ __metadata: languageName: unknown linkType: soft -"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note": +"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:^, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note": version: 0.0.0-use.local resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note" dependencies: @@ -4064,6 +4064,7 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/presets@workspace:blocksuite/presets" dependencies: + "@blocksuite/affine-block-note": "workspace:^" "@blocksuite/affine-block-surface": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-shared": "workspace:*"