mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 00:26:51 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user