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
@@ -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();
};
@@ -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,
};
@@ -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;
@@ -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) {
@@ -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;
+1
View File
@@ -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:*",
@@ -48,10 +48,11 @@ export class OutlineNotice extends SignalWatcher(
}
return html`
<div class=${styles.outlineNotice}>
<div data-testid=${AFFINE_OUTLINE_NOTICE} class=${styles.outlineNotice}>
<div class=${styles.outlineNoticeHeader}>
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
<span
data-testid="outline-notice-close-button"
class=${styles.outlineNoticeCloseButton}
@click=${() => {
this._visible$.value = false;
@@ -64,6 +65,7 @@ export class OutlineNotice extends SignalWatcher(
Some contents are not visible on edgeless.
</div>
<div
data-testid="outline-notice-sort-button"
class="${styles.button}"
@click=${() => {
this._context.enableSorting$.value = true;
@@ -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<string | null>(null);
private readonly _insertIndex$ = signal<number | undefined>(undefined);
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 _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<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() {
private _renderEmptyPanel() {
return html`<div class=${styles.emptyPanel}>
<div
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
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
const { note, newMode } = e.detail;
const { displayMode: currentMode } = note;
if (newMode === currentMode) {
return;
}
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);
}
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<string, OutlineNoteItem>,
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<NoteBlockModel, number>();
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<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() {
if (!this.doc.root) return nothing;
@@ -304,33 +354,30 @@ export class OutlinePanelBody extends SignalWatcher(
return html`<affine-outline-block-preview
class=${classMap({ active: active })}
.block=${this.doc.root}
.cardNumber=${1}
@click=${() => {
this._scrollToBlock(rootId).catch(console.error);
}}
></affine-outline-block-preview>`;
}
private _renderNoteCards(items: OutlineNoteItem[]) {
private _renderNoteCards(notes: NoteBlockModel[]) {
return repeat(
items,
item => item.note.id,
(item, idx) =>
notes,
({ id }) => id,
(note, index) =>
html`<affine-outline-note-card
data-note-id=${item.note.id}
.note=${item.note}
.number=${idx + 1}
.index=${item.index}
data-note-id=${note.id}
index=${index}
.note=${note}
.activeHeadingId=${this._activeHeadingId$.value}
.status=${this._selectedNotes$.value.includes(item.note.id)
.status=${this._selectedNotes$.value.includes(note)
? this._dragging$.value
? 'placeholder'
? 'dragging'
: 'selected'
: 'normal'}
@fitview=${this._fitToElement}
@select=${this._selectNote}
@displaymodechange=${this._handleDisplayModeChange}
@drag=${this._drag}
@clickblock=${(e: ClickBlockEvent) => {
this._scrollToBlock(e.detail.blockId).catch(console.error);
}}
@@ -341,19 +388,19 @@ export class OutlinePanelBody extends SignalWatcher(
private _renderPageVisibleCardList() {
return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
${when(
this._insertIndex$.value !== undefined,
this._dragging$.value,
() =>
html`<div
class=${styles.insertIndicator}
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
style=${`transform: translateY(${this._indicatorTranslateY$.value}px)`}
></div>`
)}
${this._renderNoteCards(this._pageVisibleNoteItems$.value)}
${this._renderNoteCards(this._pageVisibleNotes$.value)}
</div>`;
}
private _renderEdgelessOnlyCardList() {
const items = this._edgelessOnlyNoteItems$.value;
const items = this._edgelessOnlyNotes$.value;
return html`<div class=${styles.cardList}>
${when(
items.length > 0,
@@ -364,125 +411,12 @@ export class OutlinePanelBody extends SignalWatcher(
</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() {
return html`
${this._renderDocTitle()}
${when(
this._shouldRenderEmptyPanel,
() => this._EmptyPanel(),
() => this._renderEmptyPanel(),
() => html`
${this._renderPageVisibleCardList()}
${this._renderEdgelessOnlyCardList()}
@@ -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,
},
@@ -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<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._displayModeButtonGroup,
this._displayModePanel,
@@ -156,7 +161,6 @@ export class OutlineNoteCard extends SignalWatcher(
>
<div
class=${styles.cardPreview}
@mousedown=${this._dispatchDragEvent}
@click=${this._dispatchSelectEvent}
@dblclick=${this._dispatchFitViewEvent}
>
@@ -166,7 +170,9 @@ export class OutlineNoteCard extends SignalWatcher(
? html`<span class=${styles.headerIcon}
>${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>
<div class=${styles.displayModeButtonGroup}>
@@ -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;
@@ -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 extends keyof T = keyof T> = T[K];
function assertType<T>(value: unknown): asserts value is T {
noop(value);
}
@@ -206,10 +205,7 @@ export class OutlineBlockPreview extends SignalWatcher(
}
@property({ attribute: false })
accessor block!: ValuesOf<BlockSuite.BlockModels>;
@property({ attribute: false })
accessor cardNumber!: number;
accessor block!: BlockModel;
@property({ attribute: false })
accessor disabledIcon = false;
@@ -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;
};
@@ -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();
}
+1 -1
View File
@@ -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++;
}
@@ -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', () => {
+6
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) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('c', { delay: 50 });
+2 -1
View File
@@ -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:*"