refactor(editor): toc dragging with std.dnd (#9883)

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`)
This commit is contained in:
L-Sun
2025-01-24 13:27:17 +00:00
parent 351816b343
commit 829980bace
17 changed files with 689 additions and 687 deletions

View File

@@ -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();
};

View File

@@ -8,6 +8,7 @@ import {
import type { BlockCommands } from '@blocksuite/block-std'; import type { BlockCommands } from '@blocksuite/block-std';
import { updateBlockType } from './block-type.js'; import { updateBlockType } from './block-type.js';
import { changeNoteDisplayMode } from './change-note-display-mode.js';
import { dedentBlock } from './dedent-block.js'; import { dedentBlock } from './dedent-block.js';
import { dedentBlockToRoot } from './dedent-block-to-root.js'; import { dedentBlockToRoot } from './dedent-block-to-root.js';
import { dedentBlocks } from './dedent-blocks.js'; import { dedentBlocks } from './dedent-blocks.js';
@@ -37,4 +38,5 @@ export const commands: BlockCommands = {
dedentBlocks, dedentBlocks,
dedentBlockToRoot, dedentBlockToRoot,
dedentBlocksToRoot, dedentBlocksToRoot,
changeNoteDisplayMode,
}; };

View File

@@ -2,6 +2,7 @@ import type { BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
import type { updateBlockType } from './commands/block-type'; 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 { dedentBlock } from './commands/dedent-block';
import type { dedentBlockToRoot } from './commands/dedent-block-to-root'; import type { dedentBlockToRoot } from './commands/dedent-block-to-root';
import type { dedentBlocks } from './commands/dedent-blocks'; import type { dedentBlocks } from './commands/dedent-blocks';
@@ -40,6 +41,7 @@ declare global {
indentBlock: typeof indentBlock; indentBlock: typeof indentBlock;
updateBlockType: typeof updateBlockType; updateBlockType: typeof updateBlockType;
dedentBlockToRoot: typeof dedentBlockToRoot; dedentBlockToRoot: typeof dedentBlockToRoot;
changeNoteDisplayMode: typeof changeNoteDisplayMode;
} }
interface CommandContext { interface CommandContext {
focusBlock?: BlockComponent | null; focusBlock?: BlockComponent | null;

View File

@@ -195,32 +195,11 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
} }
private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) { private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) {
const { displayMode: currentMode } = note; this.edgeless.std.command.exec('changeNoteDisplayMode', {
if (newMode === currentMode) { noteId: note.id,
return; mode: newMode,
} stopCapture: true,
});
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);
}
// if change note to page only, should clear the selection // if change note to page only, should clear the selection
if (newMode === NoteDisplayMode.DocOnly) { if (newMode === NoteDisplayMode.DocOnly) {

View File

@@ -1,10 +1,11 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils'; import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets'; 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'; import { customElement, property, state } from 'lit/decorators.js';
@customElement('custom-outline-panel') @customElement('custom-outline-panel')
export class CustomOutlinePanel extends WithDisposable(LitElement) { export class CustomOutlinePanel extends WithDisposable(ShadowlessElement) {
static override styles = css` static override styles = css`
.custom-outline-container { .custom-outline-container {
position: absolute; position: absolute;

View File

@@ -12,6 +12,7 @@
"author": "toeverything", "author": "toeverything",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@blocksuite/affine-block-note": "workspace:^",
"@blocksuite/affine-block-surface": "workspace:*", "@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*", "@blocksuite/affine-shared": "workspace:*",

View File

@@ -48,10 +48,11 @@ export class OutlineNotice extends SignalWatcher(
} }
return html` return html`
<div class=${styles.outlineNotice}> <div data-testid=${AFFINE_OUTLINE_NOTICE} class=${styles.outlineNotice}>
<div class=${styles.outlineNoticeHeader}> <div class=${styles.outlineNoticeHeader}>
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span> <span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
<span <span
data-testid="outline-notice-close-button"
class=${styles.outlineNoticeCloseButton} class=${styles.outlineNoticeCloseButton}
@click=${() => { @click=${() => {
this._visible$.value = false; this._visible$.value = false;
@@ -64,6 +65,7 @@ export class OutlineNotice extends SignalWatcher(
Some contents are not visible on edgeless. Some contents are not visible on edgeless.
</div> </div>
<div <div
data-testid="outline-notice-sort-button"
class="${styles.button}" class="${styles.button}"
@click=${() => { @click=${() => {
this._context.enableSorting$.value = true; this._context.enableSorting$.value = true;

View File

@@ -1,11 +1,14 @@
import { effects } from '@blocksuite/affine-block-note/effects';
import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std'; import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
import type { NoteBlockModel } from '@blocksuite/blocks'; import type { NoteBlockModel } from '@blocksuite/blocks';
import { matchFlavours, NoteDisplayMode } from '@blocksuite/blocks';
import { import {
BlocksUtils, Bound,
matchFlavours, noop,
NoteDisplayMode, SignalWatcher,
} from '@blocksuite/blocks'; WithDisposable,
import { Bound, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context'; import { consume } from '@lit/context';
import { effect, signal } from '@preact/signals-core'; import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit'; 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 { repeat } from 'lit/directives/repeat.js';
import { when } from 'lit/directives/when.js'; import { when } from 'lit/directives/when.js';
noop(effects);
import { type TocContext, tocContext } from '../config'; import { type TocContext, tocContext } from '../config';
import type { import type {
ClickBlockEvent, ClickBlockEvent,
@@ -21,7 +26,7 @@ import type {
FitViewEvent, FitViewEvent,
SelectEvent, SelectEvent,
} from '../utils/custom-events'; } from '../utils/custom-events';
import { startDragging } from '../utils/drag'; import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import { import {
getHeadingBlocksFromDoc, getHeadingBlocksFromDoc,
getNotesFromDoc, getNotesFromDoc,
@@ -33,20 +38,6 @@ import {
} from '../utils/scroll'; } from '../utils/scroll';
import * as styles from './outline-panel-body.css'; 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 const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
export class OutlinePanelBody extends SignalWatcher( export class OutlinePanelBody extends SignalWatcher(
@@ -54,24 +45,24 @@ export class OutlinePanelBody extends SignalWatcher(
) { ) {
private readonly _activeHeadingId$ = signal<string | null>(null); private readonly _activeHeadingId$ = signal<string | null>(null);
private readonly _insertIndex$ = signal<number | undefined>(undefined);
private readonly _dragging$ = signal(false); private readonly _dragging$ = signal(false);
private readonly _pageVisibleNoteItems$ = signal<OutlineNoteItem[]>([]); private readonly _indicatorTranslateY$ = signal(0);
private readonly _edgelessOnlyNoteItems$ = signal<OutlineNoteItem[]>([]); private readonly _pageVisibleNotes$ = signal<NoteBlockModel[]>([]);
private readonly _edgelessOnlyNotes$ = signal<NoteBlockModel[]>([]);
private readonly _selectedNotes$ = signal<NoteBlockModel[]>([]);
private _clearHighlightMask = () => {}; private _clearHighlightMask = () => {};
private _indicatorTranslateY = 0;
private _lockActiveHeadingId = false; private _lockActiveHeadingId = false;
private get _shouldRenderEmptyPanel() { private get _shouldRenderEmptyPanel() {
return ( return (
this._pageVisibleNoteItems$.value.length === 0 && this._pageVisibleNotes$.value.length === 0 &&
this._edgelessOnlyNoteItems$.value.length === 0 this._edgelessOnlyNotes$.value.length === 0
); );
} }
@@ -108,91 +99,7 @@ export class OutlinePanelBody extends SignalWatcher(
}); });
} }
/* private _renderEmptyPanel() {
* 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<string, OutlineNoteItem>());
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() {
return html`<div class=${styles.emptyPanel}> return html`<div class=${styles.emptyPanel}>
<div <div
data-testid="empty-panel-placeholder" data-testid="empty-panel-placeholder"
@@ -221,33 +128,11 @@ export class OutlinePanelBody extends SignalWatcher(
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode // when display mode change to page only, we should de-select the note if it is selected in edgeless mode
private _handleDisplayModeChange(e: DisplayModeChangeEvent) { private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
const { note, newMode } = e.detail; const { note, newMode } = e.detail;
const { displayMode: currentMode } = note; this.editor.std.command.exec('changeNoteDisplayMode', {
if (newMode === currentMode) { noteId: note.id,
return; mode: newMode,
} stopCapture: true,
});
this.doc.updateBlock(note, { displayMode: newMode });
const noteParent = this.doc.getParent(note);
if (noteParent === null) {
console.error(`Failed to get parent of note(id:${note.id})`);
return;
}
const noteParentChildNotes = noteParent.children.filter(block =>
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);
}
// When the display mode of a note changed to page only // When the display mode of a note changed to page only
// We should check if the note is selected in edgeless mode // We should check if the note is selected in edgeless mode
@@ -257,35 +142,200 @@ export class OutlinePanelBody extends SignalWatcher(
} }
} }
private _moveNotes( private _moveSelectedNotes(insertIndex: number) {
index: number, if (!this.doc.root) return;
selected: string[],
notesMap: Map<string, OutlineNoteItem>,
notes: OutlineNoteItem[],
children: NoteBlockModel[]
) {
if (!children.length || !this.doc.root) return;
const blocks = selected.map( const pageVisibleNotes = this._pageVisibleNotes$.peek();
id => (notesMap.get(id) as OutlineNoteItem).note const selected = this._selectedNotes$.peek();
); const children = this.doc.root.children.slice();
const draggingBlocks = new Set(blocks);
const targetIndex = const noteIndex = new Map<NoteBlockModel, number>();
index === notes.length ? notes[index - 1].index + 1 : notes[index].index; 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 const leftPart = children
.slice(0, targetIndex) .slice(0, targetIndex)
.filter(block => !draggingBlocks.has(block)); .filter(removeSelectedNoteFilter);
const rightPart = children const rightPart = children
.slice(targetIndex) .slice(targetIndex)
.filter(block => !draggingBlocks.has(block)); .filter(removeSelectedNoteFilter);
const newChildren = [...leftPart, ...blocks, ...rightPart];
const newChildren = [...leftPart, ...selected, ...rightPart];
this.doc.updateBlock(this.doc.root, { this.doc.updateBlock(this.doc.root, {
children: newChildren, 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<NoteCardEntity, NoteDropPayload>({
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<NoteCardEntity>({
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() { private _renderDocTitle() {
if (!this.doc.root) return nothing; if (!this.doc.root) return nothing;
@@ -304,33 +354,30 @@ export class OutlinePanelBody extends SignalWatcher(
return html`<affine-outline-block-preview return html`<affine-outline-block-preview
class=${classMap({ active: active })} class=${classMap({ active: active })}
.block=${this.doc.root} .block=${this.doc.root}
.cardNumber=${1}
@click=${() => { @click=${() => {
this._scrollToBlock(rootId).catch(console.error); this._scrollToBlock(rootId).catch(console.error);
}} }}
></affine-outline-block-preview>`; ></affine-outline-block-preview>`;
} }
private _renderNoteCards(items: OutlineNoteItem[]) { private _renderNoteCards(notes: NoteBlockModel[]) {
return repeat( return repeat(
items, notes,
item => item.note.id, ({ id }) => id,
(item, idx) => (note, index) =>
html`<affine-outline-note-card html`<affine-outline-note-card
data-note-id=${item.note.id} data-note-id=${note.id}
.note=${item.note} index=${index}
.number=${idx + 1} .note=${note}
.index=${item.index}
.activeHeadingId=${this._activeHeadingId$.value} .activeHeadingId=${this._activeHeadingId$.value}
.status=${this._selectedNotes$.value.includes(item.note.id) .status=${this._selectedNotes$.value.includes(note)
? this._dragging$.value ? this._dragging$.value
? 'placeholder' ? 'dragging'
: 'selected' : 'selected'
: 'normal'} : 'normal'}
@fitview=${this._fitToElement} @fitview=${this._fitToElement}
@select=${this._selectNote} @select=${this._selectNote}
@displaymodechange=${this._handleDisplayModeChange} @displaymodechange=${this._handleDisplayModeChange}
@drag=${this._drag}
@clickblock=${(e: ClickBlockEvent) => { @clickblock=${(e: ClickBlockEvent) => {
this._scrollToBlock(e.detail.blockId).catch(console.error); this._scrollToBlock(e.detail.blockId).catch(console.error);
}} }}
@@ -341,19 +388,19 @@ export class OutlinePanelBody extends SignalWatcher(
private _renderPageVisibleCardList() { private _renderPageVisibleCardList() {
return html`<div class=${`page-visible-card-list ${styles.cardList}`}> return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
${when( ${when(
this._insertIndex$.value !== undefined, this._dragging$.value,
() => () =>
html`<div html`<div
class=${styles.insertIndicator} class=${styles.insertIndicator}
style=${`transform: translateY(${this._indicatorTranslateY}px)`} style=${`transform: translateY(${this._indicatorTranslateY$.value}px)`}
></div>` ></div>`
)} )}
${this._renderNoteCards(this._pageVisibleNoteItems$.value)} ${this._renderNoteCards(this._pageVisibleNotes$.value)}
</div>`; </div>`;
} }
private _renderEdgelessOnlyCardList() { private _renderEdgelessOnlyCardList() {
const items = this._edgelessOnlyNoteItems$.value; const items = this._edgelessOnlyNotes$.value;
return html`<div class=${styles.cardList}> return html`<div class=${styles.cardList}>
${when( ${when(
items.length > 0, items.length > 0,
@@ -364,125 +411,12 @@ export class OutlinePanelBody extends SignalWatcher(
</div>`; </div>`;
} }
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<string[]>([]);
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() { override render() {
return html` return html`
${this._renderDocTitle()} ${this._renderDocTitle()}
${when( ${when(
this._shouldRenderEmptyPanel, this._shouldRenderEmptyPanel,
() => this._EmptyPanel(), () => this._renderEmptyPanel(),
() => html` () => html`
${this._renderPageVisibleCardList()} ${this._renderPageVisibleCardList()}
${this._renderEdgelessOnlyCardList()} ${this._renderEdgelessOnlyCardList()}

View File

@@ -10,7 +10,7 @@ export const outlineCard = style({
boxSizing: 'border-box', boxSizing: 'border-box',
selectors: { selectors: {
'&[data-status="placeholder"]': { '&[data-status="dragging"]': {
pointerEvents: 'none', pointerEvents: 'none',
opacity: 0.5, opacity: 0.5,
}, },
@@ -33,7 +33,7 @@ export const cardPreview = style({
[`${outlineCard}[data-status="selected"] &`]: { [`${outlineCard}[data-status="selected"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'), background: cssVarV2('layer/background/hoverOverlay'),
}, },
[`${outlineCard}[data-status="placeholder"] &`]: { [`${outlineCard}[data-status="dragging"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'), background: cssVarV2('layer/background/hoverOverlay'),
opacity: 0.9, opacity: 0.9,
}, },

View File

@@ -3,8 +3,6 @@ import {
createButtonPopper, createButtonPopper,
type NoteBlockModel, type NoteBlockModel,
NoteDisplayMode, NoteDisplayMode,
on,
once,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit'; 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 TocContext, tocContext } from '../config';
import type { SelectEvent } from '../utils/custom-events'; import type { SelectEvent } from '../utils/custom-events';
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import * as styles from './outline-card.css'; import * as styles from './outline-card.css';
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card'; export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
@@ -39,13 +38,10 @@ export class OutlineNoteCard extends SignalWatcher(
this.dispatchEvent(event); this.dispatchEvent(event);
} }
private _dispatchDisplayModeChangeEvent( private _dispatchDisplayModeChangeEvent(newMode: NoteDisplayMode) {
note: NoteBlockModel,
newMode: NoteDisplayMode
) {
const event = new CustomEvent('displaymodechange', { const event = new CustomEvent('displaymodechange', {
detail: { detail: {
note, note: this.note,
newMode, newMode,
}, },
}); });
@@ -53,33 +49,6 @@ export class OutlineNoteCard extends SignalWatcher(
this.dispatchEvent(event); 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) { private _dispatchFitViewEvent(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
@@ -98,7 +67,6 @@ export class OutlineNoteCard extends SignalWatcher(
detail: { detail: {
id: this.note.id, id: this.note.id,
selected: this.status !== 'selected', selected: this.status !== 'selected',
number: this.number,
multiselect: e.shiftKey, multiselect: e.shiftKey,
}, },
}) as SelectEvent; }) as SelectEvent;
@@ -119,11 +87,48 @@ export class OutlineNoteCard extends SignalWatcher(
} }
} }
get invisible() { private _watchDragEvents() {
return this.note.displayMode === NoteDisplayMode.EdgelessOnly; const std = this._context.editor$.value.std;
this.disposables.add(
std.dnd.draggable<NoteCardEntity>({
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<NoteCardEntity, NoteDropPayload>({
element: this,
setDropData: () => ({
noteId: this.note.id,
}),
})
);
} }
override updated() { override connectedCallback() {
super.connectedCallback();
this._watchDragEvents();
}
override firstUpdated() {
this._displayModePopper = createButtonPopper( this._displayModePopper = createButtonPopper(
this._displayModeButtonGroup, this._displayModeButtonGroup,
this._displayModePanel, this._displayModePanel,
@@ -156,7 +161,6 @@ export class OutlineNoteCard extends SignalWatcher(
> >
<div <div
class=${styles.cardPreview} class=${styles.cardPreview}
@mousedown=${this._dispatchDragEvent}
@click=${this._dispatchSelectEvent} @click=${this._dispatchSelectEvent}
@dblclick=${this._dispatchFitViewEvent} @dblclick=${this._dispatchFitViewEvent}
> >
@@ -166,7 +170,9 @@ export class OutlineNoteCard extends SignalWatcher(
? html`<span class=${styles.headerIcon} ? html`<span class=${styles.headerIcon}
>${InvisibleIcon({ width: '20px', height: '20px' })}</span >${InvisibleIcon({ width: '20px', height: '20px' })}</span
>` >`
: html`<span class=${styles.headerNumber}>${this.number}</span>` : html`<span class=${styles.headerNumber}
>${this.index + 1}</span
>`
} }
<span class=${styles.divider}></span> <span class=${styles.divider}></span>
<div class=${styles.displayModeButtonGroup}> <div class=${styles.displayModeButtonGroup}>
@@ -194,7 +200,7 @@ export class OutlineNoteCard extends SignalWatcher(
.displayMode=${displayMode} .displayMode=${displayMode}
.panelWidth=${220} .panelWidth=${220}
.onSelect=${(newMode: NoteDisplayMode) => { .onSelect=${(newMode: NoteDisplayMode) => {
this._dispatchDisplayModeChangeEvent(this.note, newMode); this._dispatchDisplayModeChangeEvent(newMode);
this._displayModePopper?.hide(); this._displayModePopper?.hide();
}} }}
> >
@@ -206,7 +212,6 @@ export class OutlineNoteCard extends SignalWatcher(
class=${classMap({ active: this.activeHeadingId === block.id })} class=${classMap({ active: this.activeHeadingId === block.id })}
.block=${block} .block=${block}
.disabledIcon=${invisible} .disabledIcon=${invisible}
.cardNumber=${this.number}
@click=${() => { @click=${() => {
if (invisible) return; if (invisible) return;
this._dispatchClickBlockEvent(block); this._dispatchClickBlockEvent(block);
@@ -229,17 +234,14 @@ export class OutlineNoteCard extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor activeHeadingId: string | null = null; accessor activeHeadingId: string | null = null;
@property({ attribute: false })
accessor index!: number;
@property({ attribute: false }) @property({ attribute: false })
accessor note!: NoteBlockModel; accessor note!: NoteBlockModel;
@property({ attribute: false }) @property({ attribute: true, type: Number })
accessor number!: number; accessor index: number = 0;
@property({ attribute: false }) @property({ attribute: false })
accessor status: 'selected' | 'placeholder' | 'normal' = 'normal'; accessor status: 'selected' | 'dragging' | 'normal' = 'normal';
@consume({ context: tocContext }) @consume({ context: tocContext })
private accessor _context!: TocContext; private accessor _context!: TocContext;

View File

@@ -14,6 +14,7 @@ import {
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { LinkedPageIcon } from '@blocksuite/icons/lit'; import { LinkedPageIcon } from '@blocksuite/icons/lit';
import type { DeltaInsert } from '@blocksuite/inline'; import type { DeltaInsert } from '@blocksuite/inline';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context'; import { consume } from '@lit/context';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
@@ -28,8 +29,6 @@ import {
import { isHeadingBlock, isRootBlock } from '../utils/query.js'; import { isHeadingBlock, isRootBlock } from '../utils/query.js';
import * as styles from './outline-preview.css'; import * as styles from './outline-preview.css';
type ValuesOf<T, K extends keyof T = keyof T> = T[K];
function assertType<T>(value: unknown): asserts value is T { function assertType<T>(value: unknown): asserts value is T {
noop(value); noop(value);
} }
@@ -206,10 +205,7 @@ export class OutlineBlockPreview extends SignalWatcher(
} }
@property({ attribute: false }) @property({ attribute: false })
accessor block!: ValuesOf<BlockSuite.BlockModels>; accessor block!: BlockModel;
@property({ attribute: false })
accessor cardNumber!: number;
@property({ attribute: false }) @property({ attribute: false })
accessor disabledIcon = false; accessor disabledIcon = false;

View File

@@ -1,107 +1,8 @@
import { NoteDisplayMode, on, once } from '@blocksuite/blocks'; export type NoteCardEntity = {
import type { Store } from '@blocksuite/store'; type: 'toc-card';
noteId: string;
};
import type { OutlinePanelBody } from '../body/outline-panel-body.js'; export type NoteDropPayload = {
import type { OutlineNoteCard } from '../card/outline-card.js'; noteId: string;
};
/**
* 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;
}

View File

@@ -1,7 +1,8 @@
import { import {
BlocksUtils, BlocksUtils,
matchFlavours,
type NoteBlockModel, type NoteBlockModel,
type NoteDisplayMode, NoteDisplayMode,
type ParagraphBlockModel, type ParagraphBlockModel,
type RootBlockModel, type RootBlockModel,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
@@ -9,39 +10,24 @@ import type { BlockModel, Store } from '@blocksuite/store';
import { headingKeys } from '../config.js'; 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( export function getNotesFromDoc(
doc: Store, doc: Store,
modes: NoteDisplayMode[] modes: NoteDisplayMode[] = [
): OutlineNoteItem[] { NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
]
) {
const rootModel = doc.root; const rootModel = doc.root;
if (!rootModel) return []; if (!rootModel) return [];
const notes: OutlineNoteItem[] = []; const notes: NoteBlockModel[] = [];
rootModel.children.forEach((block, index) => { rootModel.children.forEach(block => {
if (!['affine:note'].includes(block.flavour)) return; if (!matchFlavours(block, ['affine:note'])) return;
const blockModel = block as NoteBlockModel; if (modes.includes(block.displayMode$.value)) {
const OutlineNoteItem = { notes.push(block);
note: block as NoteBlockModel,
index,
number: index + 1,
};
if (modes.includes(blockModel.displayMode$.value)) {
notes.push(OutlineNoteItem);
} }
}); });
@@ -75,11 +61,13 @@ export function getHeadingBlocksFromNote(
export function getHeadingBlocksFromDoc( export function getHeadingBlocksFromDoc(
doc: Store, doc: Store,
modes: NoteDisplayMode[], modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
],
ignoreEmpty = false ignoreEmpty = false
) { ) {
const notes = getNotesFromDoc(doc, modes); const notes = getNotesFromDoc(doc, modes);
return notes return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
.map(({ note }) => getHeadingBlocksFromNote(note, ignoreEmpty))
.flat();
} }

View File

@@ -302,7 +302,7 @@ export async function assertVisibleBlockCount(
// not only count, but also check if all the blocks are visible // not only count, but also check if all the blocks are visible
const locator = page.locator(`affine-${flavour}`); const locator = page.locator(`affine-${flavour}`);
let visibleCount = 0; 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()) { if (await locator.nth(i).isVisible()) {
visibleCount++; visibleCount++;
} }

View File

@@ -5,11 +5,13 @@ import {
clickView, clickView,
createEdgelessNoteBlock, createEdgelessNoteBlock,
focusDocTitle, focusDocTitle,
getEdgelessSelectedIds,
locateElementToolbar, locateElementToolbar,
} from '@affine-test/kit/utils/editor'; } from '@affine-test/kit/utils/editor';
import { import {
pressBackspace, pressBackspace,
pressEnter, pressEnter,
pressEscape,
selectAllByKeyboard, selectAllByKeyboard,
} from '@affine-test/kit/utils/keyboard'; } from '@affine-test/kit/utils/keyboard';
import { openHomePage } from '@affine-test/kit/utils/load-page'; 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 // ! Please note that when any card mode changed, the locator will be mutated
function locateCards(toc: Locator, mode?: 'both' | 'page' | 'edgeless') { function locateCards(toc: Locator, mode?: 'both' | 'page' | 'edgeless') {
const cards = toc.locator('affine-outline-note-card'); 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) { function locateSortingButton(panel: Locator) {
return panel.getByTestId('toggle-notes-sorting-button'); 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 }) => { test.beforeEach(async ({ page }) => {
await openHomePage(page); await openHomePage(page);
await clickNewPageButton(page); await clickNewPageButton(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);
}); });
test('should display title and headings when there are non-empty headings in editor', async ({ test.describe('TOC display', () => {
page, test('should display title and headings when there are non-empty headings in editor', async ({
}) => { page,
await createTitle(page); }) => {
await createHeadings(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(); await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible();
for (let i = 1; i <= 6; i++) { for (let i = 1; i <= 6; i++) {
await expect(getTocHeading(toc, i)).toBeVisible(); await expect(getTocHeading(toc, i)).toBeVisible();
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`); await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`);
} }
}); });
test('should display placeholder when no headings', async ({ page }) => { test('should display placeholder when no headings', async ({ page }) => {
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder'); const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder');
await createTitle(page); 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 pressEnter(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(); test('should not display headings when there are only empty headings', async ({
for (let i = 1; i <= 6; i++) { page,
await expect(getTocHeading(toc, i)).toBeHidden(); }) => {
} await createTitle(page);
});
test('should update panel when modify or clear title or headings', async ({ // create empty headings
page, for (let i = 1; i <= 6; i++) {
}) => { await type(page, `${'#'.repeat(i)} `);
const title = await createTitle(page); await pressEnter(page);
const headings = await createHeadings(page); }
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
await title.scrollIntoViewIfNeeded(); await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
await title.click(); for (let i = 1; i <= 6; i++) {
await type(page, 'xxx'); await expect(getTocHeading(toc, i)).toBeHidden();
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();
for (let i = 1; i <= 6; i++) { test('should update panel when modify or clear title or headings', async ({
await headings[i - 1].click(); 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 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 selectAllByKeyboard(page);
await pressBackspace(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 }) => { for (let i = 1; i <= 6; i++) {
const toc = await openTocPanel(page); await headings[i - 1].click();
await focusDocTitle(page); await type(page, 'xxx');
await page.keyboard.press('ArrowDown'); await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}xxx`);
await type(page, '# Heading 1'); await selectAllByKeyboard(page);
await pressBackspace(page);
await expect(getTocHeading(toc, i)).toBeHidden();
}
});
await clickNewPageButton(page); test('should update panel when switch doc', async ({ page }) => {
await expect(getTocHeading(toc, 1)).toBeHidden(); const toc = await openTocPanel(page);
await page.goBack(); await focusDocTitle(page);
await expect(getTocHeading(toc, 1)).toBeVisible(); await page.keyboard.press('ArrowDown');
}); await type(page, '# Heading 1');
test('should add padding to sub-headings', async ({ page }) => { await clickNewPageButton(page);
await createHeadings(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); const toc = await openTocPanel(page);
for (let i = 2; i <= 6; i++) {
const curr = getTocHeading(toc, i);
const prevRect = await prev.boundingBox(); let prev = getTocHeading(toc, 1);
const currRect = await curr.boundingBox(); for (let i = 2; i <= 6; i++) {
const curr = getTocHeading(toc, i);
expect(prevRect).not.toBeNull(); const prevRect = await prev.boundingBox();
expect(currRect).not.toBeNull(); const currRect = await curr.boundingBox();
expect(prevRect!.x).toBeLessThan(currRect!.x); expect(prevRect).not.toBeNull();
prev = curr; expect(currRect).not.toBeNull();
}
});
test('should highlight heading when scroll to area before viewport center', async ({ expect(prevRect!.x).toBeLessThan(currRect!.x);
page, prev = curr;
}) => { }
const title = await createTitle(page); });
for (let i = 0; i < 3; i++) {
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 pressEnter(page);
} await type(page, '# Heading 1');
const headings = await createHeadings(page, 10);
await title.scrollIntoViewIfNeeded();
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( await clickEdgelessModeButton(page);
page.locator('body') await expect(sortingButton).toHaveClass(/active/);
); expect(toc.locator('[data-sortable="true"]')).toHaveCount(1);
const activeHeadingContainer = toc.locator( await sortingButton.click();
'affine-outline-panel-body .active' await expect(sortingButton).not.toHaveClass(/active/);
); expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
});
await title.click(); test('should notify user when there are some page only notes and sorting is disabled', async ({
await expect(activeHeadingContainer).toContainText('Title'); 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 notification = toc.getByTestId('affine-outline-notice');
const lastHeadingCenter = await getVerticalCenterFromLocator(headings[i]); await expect(notification).toBeHidden();
await page.mouse.wheel(0, lastHeadingCenter - viewportCenter + 20);
await page.waitForTimeout(10);
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 ({ test.describe('TOC and editor scroll', () => {
page, test('should highlight heading when scroll to area before viewport center', async ({
}) => { page,
const headings = await createHeadings(page, 10); }) => {
const toc = await openTocPanel(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( const toc = await openTocPanel(page);
'affine-outline-panel-body .active'
);
const headingsInPanel = Array.from({ length: 6 }, (_, i) => const viewportCenter = await getVerticalCenterFromLocator(
getTocHeading(toc, i + 1) page.locator('body')
); );
await headingsInPanel[2].click(); const activeHeadingContainer = toc.locator(
await expect(headings[2]).toBeVisible(); 'affine-outline-panel-body .active'
await expect(activeHeadingContainer).toContainText('Heading 3'); );
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 ({ test.describe('TOC and edgeless selection', () => {
page, test('should select note blocks when selecting cards in TOC', async ({
}) => { page,
const title = await createTitle(page); }) => {
await pressEnter(page); const toc = await openTocPanel(page);
await createHeadings(page, 10); 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 cards.nth(0).click();
await titleInPanel.click(); expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
await expect(title).toBeVisible(); 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 ({ await cards.nth(1).click();
page, expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
}) => { await cards.nth(1).click();
await pressEnter(page); expect(await getEdgelessSelectedIds(page)).toHaveLength(0);
await type(page, '# Heading 1');
const toc = await openTocPanel(page); await cards.nth(2).click();
const sortingButton = locateSortingButton(toc); expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
await expect(sortingButton).not.toHaveClass(/active/); await cards.nth(2).click();
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); expect(await getEdgelessSelectedIds(page)).toHaveLength(0);
await clickEdgelessModeButton(page); await cards.nth(0).click({ modifiers: ['Shift'] });
await expect(sortingButton).toHaveClass(/active/); await cards.nth(1).click({ modifiers: ['Shift'] });
expect(toc.locator('[data-sortable="true"]')).toHaveCount(1); await cards.nth(2).click({ modifiers: ['Shift'] });
await sortingButton.click(); expect(await getEdgelessSelectedIds(page)).toHaveLength(3);
await expect(sortingButton).not.toHaveClass(/active/); });
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
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', () => { test.describe('drag and drop note in outline panel', () => {
async function changeNoteDisplayMode( const dragCard = async (
card: Locator, from: Locator,
mode: 'both' | 'doc' | 'edgeless' to: Locator,
) {
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,
position: 'before' | 'after' = 'before' position: 'before' | 'after' = 'before'
) { ) => {
const fromRect = await fromCard.boundingBox(); const fromRect = await from.boundingBox();
const toRect = await toCard.boundingBox(); const fromCenter = {
x: fromRect!.width / 2,
y: fromRect!.height / 2,
};
await page.mouse.move(fromRect!.x + 10, fromRect!.y + 10); const toRect = await to.boundingBox();
await page.mouse.down(); const toCenter = {
if (position === 'before') { x: toRect!.width / 2,
await page.mouse.move(toRect!.x + 5, toRect!.y + 5, { steps: 20 }); y: toRect!.height / 2,
} else { };
await page.mouse.move(toRect!.x + 5, toRect!.y + toRect!.height - 5, {
steps: 20, await from.dragTo(to, {
}); sourcePosition: fromCenter,
} targetPosition:
await page.mouse.up(); 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 // create 2 both cards, 2 page cards and 2 edgeless cards
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -327,7 +430,7 @@ test.describe('drag and drop note in outline panel', () => {
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const cards = locateCards(toc); const cards = locateCards(toc);
await dragNoteCard(page, cards.nth(3), cards.nth(1)); await dragCard(cards.nth(3), cards.nth(1));
await clickPageModeButton(page); await clickPageModeButton(page);
const paragraphs = 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 // Note card should be able to drag and drop in page mode
await locateSortingButton(toc).click(); 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(0)).toContainText('0');
await expect(paragraphs.nth(1)).toContainText('2'); await expect(paragraphs.nth(1)).toContainText('2');
await expect(paragraphs.nth(2)).toContainText('3'); await expect(paragraphs.nth(2)).toContainText('3');
await expect(paragraphs.nth(3)).toContainText('1'); 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', () => { test.describe('advanced visibility control', () => {

View File

@@ -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) { export async function copyByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page); await keyDownCtrlOrMeta(page);
await page.keyboard.press('c', { delay: 50 }); await page.keyboard.press('c', { delay: 50 });

View File

@@ -3551,7 +3551,7 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft 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 version: 0.0.0-use.local
resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note" resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note"
dependencies: dependencies:
@@ -4064,6 +4064,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@blocksuite/presets@workspace:blocksuite/presets" resolution: "@blocksuite/presets@workspace:blocksuite/presets"
dependencies: dependencies:
"@blocksuite/affine-block-note": "workspace:^"
"@blocksuite/affine-block-surface": "workspace:*" "@blocksuite/affine-block-surface": "workspace:*"
"@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-model": "workspace:*"
"@blocksuite/affine-shared": "workspace:*" "@blocksuite/affine-shared": "workspace:*"