mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user