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 type { BlockCommands } from '@blocksuite/block-std';
|
||||||
|
|
||||||
import { updateBlockType } from './block-type.js';
|
import { updateBlockType } from './block-type.js';
|
||||||
|
import { changeNoteDisplayMode } from './change-note-display-mode.js';
|
||||||
import { dedentBlock } from './dedent-block.js';
|
import { dedentBlock } from './dedent-block.js';
|
||||||
import { dedentBlockToRoot } from './dedent-block-to-root.js';
|
import { dedentBlockToRoot } from './dedent-block-to-root.js';
|
||||||
import { dedentBlocks } from './dedent-blocks.js';
|
import { dedentBlocks } from './dedent-blocks.js';
|
||||||
@@ -37,4 +38,5 @@ export const commands: BlockCommands = {
|
|||||||
dedentBlocks,
|
dedentBlocks,
|
||||||
dedentBlockToRoot,
|
dedentBlockToRoot,
|
||||||
dedentBlocksToRoot,
|
dedentBlocksToRoot,
|
||||||
|
changeNoteDisplayMode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BlockComponent } from '@blocksuite/block-std';
|
|||||||
import type { BlockModel } from '@blocksuite/store';
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
|
||||||
import type { updateBlockType } from './commands/block-type';
|
import type { updateBlockType } from './commands/block-type';
|
||||||
|
import type { changeNoteDisplayMode } from './commands/change-note-display-mode';
|
||||||
import type { dedentBlock } from './commands/dedent-block';
|
import type { dedentBlock } from './commands/dedent-block';
|
||||||
import type { dedentBlockToRoot } from './commands/dedent-block-to-root';
|
import type { dedentBlockToRoot } from './commands/dedent-block-to-root';
|
||||||
import type { dedentBlocks } from './commands/dedent-blocks';
|
import type { dedentBlocks } from './commands/dedent-blocks';
|
||||||
@@ -40,6 +41,7 @@ declare global {
|
|||||||
indentBlock: typeof indentBlock;
|
indentBlock: typeof indentBlock;
|
||||||
updateBlockType: typeof updateBlockType;
|
updateBlockType: typeof updateBlockType;
|
||||||
dedentBlockToRoot: typeof dedentBlockToRoot;
|
dedentBlockToRoot: typeof dedentBlockToRoot;
|
||||||
|
changeNoteDisplayMode: typeof changeNoteDisplayMode;
|
||||||
}
|
}
|
||||||
interface CommandContext {
|
interface CommandContext {
|
||||||
focusBlock?: BlockComponent | null;
|
focusBlock?: BlockComponent | null;
|
||||||
|
|||||||
@@ -195,32 +195,11 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) {
|
private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) {
|
||||||
const { displayMode: currentMode } = note;
|
this.edgeless.std.command.exec('changeNoteDisplayMode', {
|
||||||
if (newMode === currentMode) {
|
noteId: note.id,
|
||||||
return;
|
mode: newMode,
|
||||||
}
|
stopCapture: true,
|
||||||
|
});
|
||||||
this.doc.captureSync();
|
|
||||||
|
|
||||||
this.crud.updateElement(note.id, { displayMode: newMode });
|
|
||||||
|
|
||||||
const noteParent = this.doc.getParent(note);
|
|
||||||
if (!noteParent) return;
|
|
||||||
|
|
||||||
const noteParentChildNotes = noteParent.children.filter(block =>
|
|
||||||
matchFlavours(block, ['affine:note'])
|
|
||||||
);
|
|
||||||
const noteParentLastNote =
|
|
||||||
noteParentChildNotes[noteParentChildNotes.length - 1];
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentMode === NoteDisplayMode.EdgelessOnly &&
|
|
||||||
newMode !== NoteDisplayMode.EdgelessOnly &&
|
|
||||||
note !== noteParentLastNote
|
|
||||||
) {
|
|
||||||
// move to the end
|
|
||||||
this.doc.moveBlocks([note], noteParent, noteParentLastNote, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if change note to page only, should clear the selection
|
// if change note to page only, should clear the selection
|
||||||
if (newMode === NoteDisplayMode.DocOnly) {
|
if (newMode === NoteDisplayMode.DocOnly) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||||
import { WithDisposable } from '@blocksuite/global/utils';
|
import { WithDisposable } from '@blocksuite/global/utils';
|
||||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import { css, html, LitElement, nothing } from 'lit';
|
import { css, html, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
@customElement('custom-outline-panel')
|
@customElement('custom-outline-panel')
|
||||||
export class CustomOutlinePanel extends WithDisposable(LitElement) {
|
export class CustomOutlinePanel extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
.custom-outline-container {
|
.custom-outline-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"author": "toeverything",
|
"author": "toeverything",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@blocksuite/affine-block-note": "workspace:^",
|
||||||
"@blocksuite/affine-block-surface": "workspace:*",
|
"@blocksuite/affine-block-surface": "workspace:*",
|
||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ export class OutlineNotice extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class=${styles.outlineNotice}>
|
<div data-testid=${AFFINE_OUTLINE_NOTICE} class=${styles.outlineNotice}>
|
||||||
<div class=${styles.outlineNoticeHeader}>
|
<div class=${styles.outlineNoticeHeader}>
|
||||||
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
|
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid="outline-notice-close-button"
|
||||||
class=${styles.outlineNoticeCloseButton}
|
class=${styles.outlineNoticeCloseButton}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this._visible$.value = false;
|
this._visible$.value = false;
|
||||||
@@ -64,6 +65,7 @@ export class OutlineNotice extends SignalWatcher(
|
|||||||
Some contents are not visible on edgeless.
|
Some contents are not visible on edgeless.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
data-testid="outline-notice-sort-button"
|
||||||
class="${styles.button}"
|
class="${styles.button}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this._context.enableSorting$.value = true;
|
this._context.enableSorting$.value = true;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { effects } from '@blocksuite/affine-block-note/effects';
|
||||||
import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
|
import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
|
||||||
import type { NoteBlockModel } from '@blocksuite/blocks';
|
import type { NoteBlockModel } from '@blocksuite/blocks';
|
||||||
|
import { matchFlavours, NoteDisplayMode } from '@blocksuite/blocks';
|
||||||
import {
|
import {
|
||||||
BlocksUtils,
|
Bound,
|
||||||
matchFlavours,
|
noop,
|
||||||
NoteDisplayMode,
|
SignalWatcher,
|
||||||
} from '@blocksuite/blocks';
|
WithDisposable,
|
||||||
import { Bound, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
} from '@blocksuite/global/utils';
|
||||||
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
import { consume } from '@lit/context';
|
import { consume } from '@lit/context';
|
||||||
import { effect, signal } from '@preact/signals-core';
|
import { effect, signal } from '@preact/signals-core';
|
||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
@@ -14,6 +17,8 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
import { when } from 'lit/directives/when.js';
|
import { when } from 'lit/directives/when.js';
|
||||||
|
|
||||||
|
noop(effects);
|
||||||
|
|
||||||
import { type TocContext, tocContext } from '../config';
|
import { type TocContext, tocContext } from '../config';
|
||||||
import type {
|
import type {
|
||||||
ClickBlockEvent,
|
ClickBlockEvent,
|
||||||
@@ -21,7 +26,7 @@ import type {
|
|||||||
FitViewEvent,
|
FitViewEvent,
|
||||||
SelectEvent,
|
SelectEvent,
|
||||||
} from '../utils/custom-events';
|
} from '../utils/custom-events';
|
||||||
import { startDragging } from '../utils/drag';
|
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
|
||||||
import {
|
import {
|
||||||
getHeadingBlocksFromDoc,
|
getHeadingBlocksFromDoc,
|
||||||
getNotesFromDoc,
|
getNotesFromDoc,
|
||||||
@@ -33,20 +38,6 @@ import {
|
|||||||
} from '../utils/scroll';
|
} from '../utils/scroll';
|
||||||
import * as styles from './outline-panel-body.css';
|
import * as styles from './outline-panel-body.css';
|
||||||
|
|
||||||
type OutlineNoteItem = {
|
|
||||||
note: NoteBlockModel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the index of the note inside its parent's children property
|
|
||||||
*/
|
|
||||||
index: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the number displayed on the outline panel
|
|
||||||
*/
|
|
||||||
number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
|
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
|
||||||
|
|
||||||
export class OutlinePanelBody extends SignalWatcher(
|
export class OutlinePanelBody extends SignalWatcher(
|
||||||
@@ -54,24 +45,24 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
) {
|
) {
|
||||||
private readonly _activeHeadingId$ = signal<string | null>(null);
|
private readonly _activeHeadingId$ = signal<string | null>(null);
|
||||||
|
|
||||||
private readonly _insertIndex$ = signal<number | undefined>(undefined);
|
|
||||||
|
|
||||||
private readonly _dragging$ = signal(false);
|
private readonly _dragging$ = signal(false);
|
||||||
|
|
||||||
private readonly _pageVisibleNoteItems$ = signal<OutlineNoteItem[]>([]);
|
private readonly _indicatorTranslateY$ = signal(0);
|
||||||
|
|
||||||
private readonly _edgelessOnlyNoteItems$ = signal<OutlineNoteItem[]>([]);
|
private readonly _pageVisibleNotes$ = signal<NoteBlockModel[]>([]);
|
||||||
|
|
||||||
|
private readonly _edgelessOnlyNotes$ = signal<NoteBlockModel[]>([]);
|
||||||
|
|
||||||
|
private readonly _selectedNotes$ = signal<NoteBlockModel[]>([]);
|
||||||
|
|
||||||
private _clearHighlightMask = () => {};
|
private _clearHighlightMask = () => {};
|
||||||
|
|
||||||
private _indicatorTranslateY = 0;
|
|
||||||
|
|
||||||
private _lockActiveHeadingId = false;
|
private _lockActiveHeadingId = false;
|
||||||
|
|
||||||
private get _shouldRenderEmptyPanel() {
|
private get _shouldRenderEmptyPanel() {
|
||||||
return (
|
return (
|
||||||
this._pageVisibleNoteItems$.value.length === 0 &&
|
this._pageVisibleNotes$.value.length === 0 &&
|
||||||
this._edgelessOnlyNoteItems$.value.length === 0
|
this._edgelessOnlyNotes$.value.length === 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,91 +99,7 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
private _renderEmptyPanel() {
|
||||||
* Double click at blank area to disable notes sorting option
|
|
||||||
*/
|
|
||||||
private readonly _doubleClickHandler = (e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const enableSorting = this._context.enableSorting$.peek();
|
|
||||||
// check if click at outline-card, if so, do nothing
|
|
||||||
if (
|
|
||||||
(e.target as HTMLElement).closest('outline-note-card') ||
|
|
||||||
!enableSorting
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._context.enableSorting$.value = !enableSorting;
|
|
||||||
};
|
|
||||||
|
|
||||||
private _drag() {
|
|
||||||
const pageVisibleNotes = this._pageVisibleNoteItems$.peek();
|
|
||||||
|
|
||||||
const selectedVisibleNotes = this._selectedNotes$.peek().filter(id => {
|
|
||||||
const model = this.doc.getBlock(id)?.model;
|
|
||||||
return (
|
|
||||||
model &&
|
|
||||||
matchFlavours(model, ['affine:note']) &&
|
|
||||||
model.displayMode !== NoteDisplayMode.EdgelessOnly
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
selectedVisibleNotes.length === 0 ||
|
|
||||||
!pageVisibleNotes.length ||
|
|
||||||
!this.doc.root
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (this.edgeless) {
|
|
||||||
this.edgeless.service.selection.set({
|
|
||||||
elements: selectedVisibleNotes,
|
|
||||||
editing: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this._selectedNotes$.value = selectedVisibleNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._dragging$.value = true;
|
|
||||||
|
|
||||||
// cache the notes in case it is changed by other peers
|
|
||||||
const children = this.doc.root.children.slice() as NoteBlockModel[];
|
|
||||||
const notesMap = pageVisibleNotes.reduce((map, note, index) => {
|
|
||||||
map.set(note.note.id, {
|
|
||||||
...note,
|
|
||||||
number: index + 1,
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, new Map<string, OutlineNoteItem>());
|
|
||||||
|
|
||||||
startDragging({
|
|
||||||
container: this,
|
|
||||||
document: this.ownerDocument,
|
|
||||||
host: this.ownerDocument,
|
|
||||||
doc: this.doc,
|
|
||||||
outlineListContainer: this._pageVisibleList,
|
|
||||||
onDragEnd: insertIdx => {
|
|
||||||
this._dragging$.value = false;
|
|
||||||
this._insertIndex$.value = undefined;
|
|
||||||
|
|
||||||
if (insertIdx === undefined) return;
|
|
||||||
|
|
||||||
this._moveNotes(
|
|
||||||
insertIdx,
|
|
||||||
selectedVisibleNotes,
|
|
||||||
notesMap,
|
|
||||||
pageVisibleNotes,
|
|
||||||
children
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDragMove: (idx, indicatorTranslateY) => {
|
|
||||||
this._insertIndex$.value = idx;
|
|
||||||
this._indicatorTranslateY = indicatorTranslateY ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _EmptyPanel() {
|
|
||||||
return html`<div class=${styles.emptyPanel}>
|
return html`<div class=${styles.emptyPanel}>
|
||||||
<div
|
<div
|
||||||
data-testid="empty-panel-placeholder"
|
data-testid="empty-panel-placeholder"
|
||||||
@@ -221,33 +128,11 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode
|
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode
|
||||||
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
|
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
|
||||||
const { note, newMode } = e.detail;
|
const { note, newMode } = e.detail;
|
||||||
const { displayMode: currentMode } = note;
|
this.editor.std.command.exec('changeNoteDisplayMode', {
|
||||||
if (newMode === currentMode) {
|
noteId: note.id,
|
||||||
return;
|
mode: newMode,
|
||||||
}
|
stopCapture: true,
|
||||||
|
});
|
||||||
this.doc.updateBlock(note, { displayMode: newMode });
|
|
||||||
|
|
||||||
const noteParent = this.doc.getParent(note);
|
|
||||||
if (noteParent === null) {
|
|
||||||
console.error(`Failed to get parent of note(id:${note.id})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteParentChildNotes = noteParent.children.filter(block =>
|
|
||||||
BlocksUtils.matchFlavours(block, ['affine:note'])
|
|
||||||
) as NoteBlockModel[];
|
|
||||||
const noteParentLastNote =
|
|
||||||
noteParentChildNotes[noteParentChildNotes.length - 1];
|
|
||||||
|
|
||||||
// When the display mode of a note change from edgeless to page visible
|
|
||||||
// We should move the note to the end of the note list
|
|
||||||
if (
|
|
||||||
currentMode === NoteDisplayMode.EdgelessOnly &&
|
|
||||||
note !== noteParentLastNote
|
|
||||||
) {
|
|
||||||
this.doc.moveBlocks([note], noteParent, noteParentLastNote, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the display mode of a note changed to page only
|
// When the display mode of a note changed to page only
|
||||||
// We should check if the note is selected in edgeless mode
|
// We should check if the note is selected in edgeless mode
|
||||||
@@ -257,35 +142,200 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _moveNotes(
|
private _moveSelectedNotes(insertIndex: number) {
|
||||||
index: number,
|
if (!this.doc.root) return;
|
||||||
selected: string[],
|
|
||||||
notesMap: Map<string, OutlineNoteItem>,
|
|
||||||
notes: OutlineNoteItem[],
|
|
||||||
children: NoteBlockModel[]
|
|
||||||
) {
|
|
||||||
if (!children.length || !this.doc.root) return;
|
|
||||||
|
|
||||||
const blocks = selected.map(
|
const pageVisibleNotes = this._pageVisibleNotes$.peek();
|
||||||
id => (notesMap.get(id) as OutlineNoteItem).note
|
const selected = this._selectedNotes$.peek();
|
||||||
);
|
const children = this.doc.root.children.slice();
|
||||||
const draggingBlocks = new Set(blocks);
|
|
||||||
const targetIndex =
|
const noteIndex = new Map<NoteBlockModel, number>();
|
||||||
index === notes.length ? notes[index - 1].index + 1 : notes[index].index;
|
children.forEach((block, index) => {
|
||||||
|
if (matchFlavours(block, ['affine:note'])) {
|
||||||
|
noteIndex.set(block, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let targetIndex: number | null = null;
|
||||||
|
if (insertIndex === pageVisibleNotes.length) {
|
||||||
|
const temp = noteIndex.get(pageVisibleNotes[insertIndex - 1]);
|
||||||
|
if (temp) targetIndex = temp + 1;
|
||||||
|
} else {
|
||||||
|
targetIndex = noteIndex.get(pageVisibleNotes[insertIndex]) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === null) return;
|
||||||
|
|
||||||
|
const removeSelectedNoteFilter = (block: BlockModel) =>
|
||||||
|
!matchFlavours(block, ['affine:note']) || !selected.includes(block);
|
||||||
|
|
||||||
const leftPart = children
|
const leftPart = children
|
||||||
.slice(0, targetIndex)
|
.slice(0, targetIndex)
|
||||||
.filter(block => !draggingBlocks.has(block));
|
.filter(removeSelectedNoteFilter);
|
||||||
const rightPart = children
|
const rightPart = children
|
||||||
.slice(targetIndex)
|
.slice(targetIndex)
|
||||||
.filter(block => !draggingBlocks.has(block));
|
.filter(removeSelectedNoteFilter);
|
||||||
const newChildren = [...leftPart, ...blocks, ...rightPart];
|
|
||||||
|
const newChildren = [...leftPart, ...selected, ...rightPart];
|
||||||
|
|
||||||
this.doc.updateBlock(this.doc.root, {
|
this.doc.updateBlock(this.doc.root, {
|
||||||
children: newChildren,
|
children: newChildren,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _scrollToBlock(blockId: string) {
|
||||||
|
this._lockActiveHeadingId = true;
|
||||||
|
this._activeHeadingId$.value = blockId;
|
||||||
|
this._clearHighlightMask = await scrollToBlockWithHighlight(
|
||||||
|
this.editor,
|
||||||
|
blockId
|
||||||
|
);
|
||||||
|
this._lockActiveHeadingId = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _selectNote(e: SelectEvent) {
|
||||||
|
const { selected, id, multiselect } = e.detail;
|
||||||
|
const note = this.doc.getBlock(id)?.model;
|
||||||
|
if (!note || !matchFlavours(note, ['affine:note'])) return;
|
||||||
|
|
||||||
|
let selectedNotes = this._selectedNotes$.peek();
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
selectedNotes = selectedNotes.filter(_note => _note !== note);
|
||||||
|
} else if (multiselect) {
|
||||||
|
selectedNotes = [...selectedNotes, note];
|
||||||
|
} else {
|
||||||
|
selectedNotes = [note];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.edgeless) {
|
||||||
|
this.edgeless?.service.selection.set({
|
||||||
|
elements: selectedNotes.map(({ id }) => id),
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._selectedNotes$.value = selectedNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _watchSelectedNotes() {
|
||||||
|
return effect(() => {
|
||||||
|
const { std, doc, mode } = this.editor;
|
||||||
|
if (mode !== 'edgeless') return;
|
||||||
|
|
||||||
|
const currSelectedNotes = std.selection
|
||||||
|
.filter(SurfaceSelection)
|
||||||
|
.map(({ blockId }) => doc.getBlock(blockId)?.model)
|
||||||
|
.filter(model => {
|
||||||
|
return !!model && matchFlavours(model, ['affine:note']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preSelected = this._selectedNotes$.peek();
|
||||||
|
if (
|
||||||
|
preSelected.length !== currSelectedNotes.length ||
|
||||||
|
preSelected.some(note => !currSelectedNotes.includes(note))
|
||||||
|
) {
|
||||||
|
this._selectedNotes$.value = currSelectedNotes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _watchNotes() {
|
||||||
|
this.disposables.add(
|
||||||
|
effect(() => {
|
||||||
|
const isRenderableNote = (note: NoteBlockModel) => {
|
||||||
|
let hasHeadings = false;
|
||||||
|
|
||||||
|
for (const block of note.children) {
|
||||||
|
if (isHeadingBlock(block)) {
|
||||||
|
hasHeadings = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasHeadings || this._context.enableSorting$.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [
|
||||||
|
NoteDisplayMode.DocAndEdgeless,
|
||||||
|
NoteDisplayMode.DocOnly,
|
||||||
|
]).filter(isRenderableNote);
|
||||||
|
|
||||||
|
this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [
|
||||||
|
NoteDisplayMode.EdgelessOnly,
|
||||||
|
]).filter(isRenderableNote);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _watchDragAndDrop() {
|
||||||
|
const std = this.editor.std;
|
||||||
|
this.disposables.add(
|
||||||
|
std.dnd.monitor<NoteCardEntity, NoteDropPayload>({
|
||||||
|
onDragStart: () => {
|
||||||
|
this._dragging$.value = true;
|
||||||
|
this._selectedNotes$.value = this._selectedNotes$
|
||||||
|
.peek()
|
||||||
|
.filter(note => {
|
||||||
|
return this._pageVisibleNotes$.value.includes(note);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDrag: data => {
|
||||||
|
const target = data.location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
const edge = target.data.edge;
|
||||||
|
const rect = target.element.getBoundingClientRect();
|
||||||
|
const parentRect = this._pageVisibleList.getBoundingClientRect();
|
||||||
|
this._indicatorTranslateY$.value =
|
||||||
|
edge === 'top'
|
||||||
|
? rect.top - parentRect.top
|
||||||
|
: rect.bottom - parentRect.top;
|
||||||
|
},
|
||||||
|
onDrop: data => {
|
||||||
|
this._dragging$.value = false;
|
||||||
|
const target = data.location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const edge = target.data.edge;
|
||||||
|
const index = this._pageVisibleNotes$
|
||||||
|
.peek()
|
||||||
|
.findIndex(({ id }) => id === target.data.noteId);
|
||||||
|
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
this._moveSelectedNotes(edge === 'top' ? index : index + 1);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.disposables.add(
|
||||||
|
std.dnd.autoScroll<NoteCardEntity>({
|
||||||
|
element: this,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.classList.add(styles.outlinePanelBody);
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
observeActiveHeadingDuringScroll(
|
||||||
|
() => this.editor,
|
||||||
|
newHeadingId => {
|
||||||
|
if (this._lockActiveHeadingId) return;
|
||||||
|
this._activeHeadingId$.value = newHeadingId;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this._watchNotes();
|
||||||
|
this._watchSelectedNotes();
|
||||||
|
this._watchDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._clearHighlightMask();
|
||||||
|
}
|
||||||
|
|
||||||
private _renderDocTitle() {
|
private _renderDocTitle() {
|
||||||
if (!this.doc.root) return nothing;
|
if (!this.doc.root) return nothing;
|
||||||
|
|
||||||
@@ -304,33 +354,30 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
return html`<affine-outline-block-preview
|
return html`<affine-outline-block-preview
|
||||||
class=${classMap({ active: active })}
|
class=${classMap({ active: active })}
|
||||||
.block=${this.doc.root}
|
.block=${this.doc.root}
|
||||||
.cardNumber=${1}
|
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this._scrollToBlock(rootId).catch(console.error);
|
this._scrollToBlock(rootId).catch(console.error);
|
||||||
}}
|
}}
|
||||||
></affine-outline-block-preview>`;
|
></affine-outline-block-preview>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderNoteCards(items: OutlineNoteItem[]) {
|
private _renderNoteCards(notes: NoteBlockModel[]) {
|
||||||
return repeat(
|
return repeat(
|
||||||
items,
|
notes,
|
||||||
item => item.note.id,
|
({ id }) => id,
|
||||||
(item, idx) =>
|
(note, index) =>
|
||||||
html`<affine-outline-note-card
|
html`<affine-outline-note-card
|
||||||
data-note-id=${item.note.id}
|
data-note-id=${note.id}
|
||||||
.note=${item.note}
|
index=${index}
|
||||||
.number=${idx + 1}
|
.note=${note}
|
||||||
.index=${item.index}
|
|
||||||
.activeHeadingId=${this._activeHeadingId$.value}
|
.activeHeadingId=${this._activeHeadingId$.value}
|
||||||
.status=${this._selectedNotes$.value.includes(item.note.id)
|
.status=${this._selectedNotes$.value.includes(note)
|
||||||
? this._dragging$.value
|
? this._dragging$.value
|
||||||
? 'placeholder'
|
? 'dragging'
|
||||||
: 'selected'
|
: 'selected'
|
||||||
: 'normal'}
|
: 'normal'}
|
||||||
@fitview=${this._fitToElement}
|
@fitview=${this._fitToElement}
|
||||||
@select=${this._selectNote}
|
@select=${this._selectNote}
|
||||||
@displaymodechange=${this._handleDisplayModeChange}
|
@displaymodechange=${this._handleDisplayModeChange}
|
||||||
@drag=${this._drag}
|
|
||||||
@clickblock=${(e: ClickBlockEvent) => {
|
@clickblock=${(e: ClickBlockEvent) => {
|
||||||
this._scrollToBlock(e.detail.blockId).catch(console.error);
|
this._scrollToBlock(e.detail.blockId).catch(console.error);
|
||||||
}}
|
}}
|
||||||
@@ -341,19 +388,19 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
private _renderPageVisibleCardList() {
|
private _renderPageVisibleCardList() {
|
||||||
return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
|
return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
|
||||||
${when(
|
${when(
|
||||||
this._insertIndex$.value !== undefined,
|
this._dragging$.value,
|
||||||
() =>
|
() =>
|
||||||
html`<div
|
html`<div
|
||||||
class=${styles.insertIndicator}
|
class=${styles.insertIndicator}
|
||||||
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
|
style=${`transform: translateY(${this._indicatorTranslateY$.value}px)`}
|
||||||
></div>`
|
></div>`
|
||||||
)}
|
)}
|
||||||
${this._renderNoteCards(this._pageVisibleNoteItems$.value)}
|
${this._renderNoteCards(this._pageVisibleNotes$.value)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderEdgelessOnlyCardList() {
|
private _renderEdgelessOnlyCardList() {
|
||||||
const items = this._edgelessOnlyNoteItems$.value;
|
const items = this._edgelessOnlyNotes$.value;
|
||||||
return html`<div class=${styles.cardList}>
|
return html`<div class=${styles.cardList}>
|
||||||
${when(
|
${when(
|
||||||
items.length > 0,
|
items.length > 0,
|
||||||
@@ -364,125 +411,12 @@ export class OutlinePanelBody extends SignalWatcher(
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _scrollToBlock(blockId: string) {
|
|
||||||
this._lockActiveHeadingId = true;
|
|
||||||
this._activeHeadingId$.value = blockId;
|
|
||||||
this._clearHighlightMask = await scrollToBlockWithHighlight(
|
|
||||||
this.editor,
|
|
||||||
blockId
|
|
||||||
);
|
|
||||||
this._lockActiveHeadingId = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly _selectedNotes$ = signal<string[]>([]);
|
|
||||||
|
|
||||||
private _selectNote(e: SelectEvent) {
|
|
||||||
const { selected, id, multiselect } = e.detail;
|
|
||||||
|
|
||||||
let selectedNotes = this._selectedNotes$.peek();
|
|
||||||
|
|
||||||
if (!selected) {
|
|
||||||
selectedNotes = selectedNotes.filter(noteId => noteId !== id);
|
|
||||||
} else if (multiselect) {
|
|
||||||
selectedNotes = [...selectedNotes, id];
|
|
||||||
} else {
|
|
||||||
selectedNotes = [id];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.edgeless) {
|
|
||||||
this.edgeless?.service.selection.set({
|
|
||||||
elements: selectedNotes,
|
|
||||||
editing: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this._selectedNotes$.value = selectedNotes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _watchSelectedNotes() {
|
|
||||||
return effect(() => {
|
|
||||||
const { std, doc, mode } = this.editor;
|
|
||||||
if (mode !== 'edgeless') return;
|
|
||||||
|
|
||||||
const currSelectedNotes = std.selection
|
|
||||||
.filter(SurfaceSelection)
|
|
||||||
.filter(({ blockId }) => {
|
|
||||||
const model = doc.getBlock(blockId)?.model;
|
|
||||||
return !!model && matchFlavours(model, ['affine:note']);
|
|
||||||
})
|
|
||||||
.map(({ blockId }) => blockId);
|
|
||||||
|
|
||||||
const preSelected = this._selectedNotes$.peek();
|
|
||||||
if (
|
|
||||||
preSelected.length !== currSelectedNotes.length ||
|
|
||||||
preSelected.some(id => !currSelectedNotes.includes(id))
|
|
||||||
) {
|
|
||||||
this._selectedNotes$.value = currSelectedNotes;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _watchNotes() {
|
|
||||||
this.disposables.add(
|
|
||||||
effect(() => {
|
|
||||||
if (this._dragging$.value) return;
|
|
||||||
|
|
||||||
const isRenderableNote = (item: OutlineNoteItem) => {
|
|
||||||
let hasHeadings = false;
|
|
||||||
|
|
||||||
for (const block of item.note.children) {
|
|
||||||
if (isHeadingBlock(block)) {
|
|
||||||
hasHeadings = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasHeadings || this._context.enableSorting$.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
this._pageVisibleNoteItems$.value = getNotesFromDoc(this.doc, [
|
|
||||||
NoteDisplayMode.DocAndEdgeless,
|
|
||||||
NoteDisplayMode.DocOnly,
|
|
||||||
]).filter(isRenderableNote);
|
|
||||||
|
|
||||||
this._edgelessOnlyNoteItems$.value = getNotesFromDoc(this.doc, [
|
|
||||||
NoteDisplayMode.EdgelessOnly,
|
|
||||||
]).filter(isRenderableNote);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.classList.add(styles.outlinePanelBody);
|
|
||||||
|
|
||||||
this.disposables.add(
|
|
||||||
observeActiveHeadingDuringScroll(
|
|
||||||
() => this.editor,
|
|
||||||
newHeadingId => {
|
|
||||||
if (this._lockActiveHeadingId) return;
|
|
||||||
this._activeHeadingId$.value = newHeadingId;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
this._watchNotes();
|
|
||||||
this._watchSelectedNotes();
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
this._clearHighlightMask();
|
|
||||||
}
|
|
||||||
|
|
||||||
override firstUpdated(): void {
|
|
||||||
this.disposables.addFromEvent(this, 'dblclick', this._doubleClickHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
${this._renderDocTitle()}
|
${this._renderDocTitle()}
|
||||||
${when(
|
${when(
|
||||||
this._shouldRenderEmptyPanel,
|
this._shouldRenderEmptyPanel,
|
||||||
() => this._EmptyPanel(),
|
() => this._renderEmptyPanel(),
|
||||||
() => html`
|
() => html`
|
||||||
${this._renderPageVisibleCardList()}
|
${this._renderPageVisibleCardList()}
|
||||||
${this._renderEdgelessOnlyCardList()}
|
${this._renderEdgelessOnlyCardList()}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const outlineCard = style({
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-status="placeholder"]': {
|
'&[data-status="dragging"]': {
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@ export const cardPreview = style({
|
|||||||
[`${outlineCard}[data-status="selected"] &`]: {
|
[`${outlineCard}[data-status="selected"] &`]: {
|
||||||
background: cssVarV2('layer/background/hoverOverlay'),
|
background: cssVarV2('layer/background/hoverOverlay'),
|
||||||
},
|
},
|
||||||
[`${outlineCard}[data-status="placeholder"] &`]: {
|
[`${outlineCard}[data-status="dragging"] &`]: {
|
||||||
background: cssVarV2('layer/background/hoverOverlay'),
|
background: cssVarV2('layer/background/hoverOverlay'),
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
createButtonPopper,
|
createButtonPopper,
|
||||||
type NoteBlockModel,
|
type NoteBlockModel,
|
||||||
NoteDisplayMode,
|
NoteDisplayMode,
|
||||||
on,
|
|
||||||
once,
|
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||||
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit';
|
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit';
|
||||||
@@ -17,6 +15,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
|
|
||||||
import { type TocContext, tocContext } from '../config';
|
import { type TocContext, tocContext } from '../config';
|
||||||
import type { SelectEvent } from '../utils/custom-events';
|
import type { SelectEvent } from '../utils/custom-events';
|
||||||
|
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
|
||||||
import * as styles from './outline-card.css';
|
import * as styles from './outline-card.css';
|
||||||
|
|
||||||
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
|
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
|
||||||
@@ -39,13 +38,10 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dispatchDisplayModeChangeEvent(
|
private _dispatchDisplayModeChangeEvent(newMode: NoteDisplayMode) {
|
||||||
note: NoteBlockModel,
|
|
||||||
newMode: NoteDisplayMode
|
|
||||||
) {
|
|
||||||
const event = new CustomEvent('displaymodechange', {
|
const event = new CustomEvent('displaymodechange', {
|
||||||
detail: {
|
detail: {
|
||||||
note,
|
note: this.note,
|
||||||
newMode,
|
newMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,33 +49,6 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dispatchDragEvent(e: MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.button !== 0 || !this._context.enableSorting$.peek()) return;
|
|
||||||
|
|
||||||
const { clientX: startX, clientY: startY } = e;
|
|
||||||
const disposeDragStart = on(this.ownerDocument, 'mousemove', e => {
|
|
||||||
if (
|
|
||||||
Math.abs(startX - e.clientX) < 5 &&
|
|
||||||
Math.abs(startY - e.clientY) < 5
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.status !== 'selected') {
|
|
||||||
this._dispatchSelectEvent(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = new CustomEvent('drag');
|
|
||||||
|
|
||||||
this.dispatchEvent(event);
|
|
||||||
disposeDragStart();
|
|
||||||
});
|
|
||||||
|
|
||||||
once(this.ownerDocument, 'mouseup', () => {
|
|
||||||
disposeDragStart();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _dispatchFitViewEvent(e: MouseEvent) {
|
private _dispatchFitViewEvent(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@@ -98,7 +67,6 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
detail: {
|
detail: {
|
||||||
id: this.note.id,
|
id: this.note.id,
|
||||||
selected: this.status !== 'selected',
|
selected: this.status !== 'selected',
|
||||||
number: this.number,
|
|
||||||
multiselect: e.shiftKey,
|
multiselect: e.shiftKey,
|
||||||
},
|
},
|
||||||
}) as SelectEvent;
|
}) as SelectEvent;
|
||||||
@@ -119,11 +87,48 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get invisible() {
|
private _watchDragEvents() {
|
||||||
return this.note.displayMode === NoteDisplayMode.EdgelessOnly;
|
const std = this._context.editor$.value.std;
|
||||||
|
this.disposables.add(
|
||||||
|
std.dnd.draggable<NoteCardEntity>({
|
||||||
|
element: this,
|
||||||
|
canDrag: () => this.note.displayMode !== NoteDisplayMode.EdgelessOnly,
|
||||||
|
onDragStart: () => {
|
||||||
|
if (this.status !== 'selected') {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('select', {
|
||||||
|
detail: {
|
||||||
|
id: this.note.id,
|
||||||
|
selected: true,
|
||||||
|
multiselect: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setDragData: () => ({
|
||||||
|
type: 'toc-card',
|
||||||
|
noteId: this.note.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
std.dnd.dropTarget<NoteCardEntity, NoteDropPayload>({
|
||||||
|
element: this,
|
||||||
|
setDropData: () => ({
|
||||||
|
noteId: this.note.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._watchDragEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
override firstUpdated() {
|
||||||
this._displayModePopper = createButtonPopper(
|
this._displayModePopper = createButtonPopper(
|
||||||
this._displayModeButtonGroup,
|
this._displayModeButtonGroup,
|
||||||
this._displayModePanel,
|
this._displayModePanel,
|
||||||
@@ -156,7 +161,6 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class=${styles.cardPreview}
|
class=${styles.cardPreview}
|
||||||
@mousedown=${this._dispatchDragEvent}
|
|
||||||
@click=${this._dispatchSelectEvent}
|
@click=${this._dispatchSelectEvent}
|
||||||
@dblclick=${this._dispatchFitViewEvent}
|
@dblclick=${this._dispatchFitViewEvent}
|
||||||
>
|
>
|
||||||
@@ -166,7 +170,9 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
? html`<span class=${styles.headerIcon}
|
? html`<span class=${styles.headerIcon}
|
||||||
>${InvisibleIcon({ width: '20px', height: '20px' })}</span
|
>${InvisibleIcon({ width: '20px', height: '20px' })}</span
|
||||||
>`
|
>`
|
||||||
: html`<span class=${styles.headerNumber}>${this.number}</span>`
|
: html`<span class=${styles.headerNumber}
|
||||||
|
>${this.index + 1}</span
|
||||||
|
>`
|
||||||
}
|
}
|
||||||
<span class=${styles.divider}></span>
|
<span class=${styles.divider}></span>
|
||||||
<div class=${styles.displayModeButtonGroup}>
|
<div class=${styles.displayModeButtonGroup}>
|
||||||
@@ -194,7 +200,7 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
.displayMode=${displayMode}
|
.displayMode=${displayMode}
|
||||||
.panelWidth=${220}
|
.panelWidth=${220}
|
||||||
.onSelect=${(newMode: NoteDisplayMode) => {
|
.onSelect=${(newMode: NoteDisplayMode) => {
|
||||||
this._dispatchDisplayModeChangeEvent(this.note, newMode);
|
this._dispatchDisplayModeChangeEvent(newMode);
|
||||||
this._displayModePopper?.hide();
|
this._displayModePopper?.hide();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -206,7 +212,6 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
class=${classMap({ active: this.activeHeadingId === block.id })}
|
class=${classMap({ active: this.activeHeadingId === block.id })}
|
||||||
.block=${block}
|
.block=${block}
|
||||||
.disabledIcon=${invisible}
|
.disabledIcon=${invisible}
|
||||||
.cardNumber=${this.number}
|
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (invisible) return;
|
if (invisible) return;
|
||||||
this._dispatchClickBlockEvent(block);
|
this._dispatchClickBlockEvent(block);
|
||||||
@@ -229,17 +234,14 @@ export class OutlineNoteCard extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor activeHeadingId: string | null = null;
|
accessor activeHeadingId: string | null = null;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor index!: number;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor note!: NoteBlockModel;
|
accessor note!: NoteBlockModel;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: true, type: Number })
|
||||||
accessor number!: number;
|
accessor index: number = 0;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor status: 'selected' | 'placeholder' | 'normal' = 'normal';
|
accessor status: 'selected' | 'dragging' | 'normal' = 'normal';
|
||||||
|
|
||||||
@consume({ context: tocContext })
|
@consume({ context: tocContext })
|
||||||
private accessor _context!: TocContext;
|
private accessor _context!: TocContext;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||||
import type { DeltaInsert } from '@blocksuite/inline';
|
import type { DeltaInsert } from '@blocksuite/inline';
|
||||||
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
import { consume } from '@lit/context';
|
import { consume } from '@lit/context';
|
||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
@@ -28,8 +29,6 @@ import {
|
|||||||
import { isHeadingBlock, isRootBlock } from '../utils/query.js';
|
import { isHeadingBlock, isRootBlock } from '../utils/query.js';
|
||||||
import * as styles from './outline-preview.css';
|
import * as styles from './outline-preview.css';
|
||||||
|
|
||||||
type ValuesOf<T, K extends keyof T = keyof T> = T[K];
|
|
||||||
|
|
||||||
function assertType<T>(value: unknown): asserts value is T {
|
function assertType<T>(value: unknown): asserts value is T {
|
||||||
noop(value);
|
noop(value);
|
||||||
}
|
}
|
||||||
@@ -206,10 +205,7 @@ export class OutlineBlockPreview extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor block!: ValuesOf<BlockSuite.BlockModels>;
|
accessor block!: BlockModel;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor cardNumber!: number;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor disabledIcon = false;
|
accessor disabledIcon = false;
|
||||||
|
|||||||
@@ -1,107 +1,8 @@
|
|||||||
import { NoteDisplayMode, on, once } from '@blocksuite/blocks';
|
export type NoteCardEntity = {
|
||||||
import type { Store } from '@blocksuite/store';
|
type: 'toc-card';
|
||||||
|
noteId: string;
|
||||||
|
};
|
||||||
|
|
||||||
import type { OutlinePanelBody } from '../body/outline-panel-body.js';
|
export type NoteDropPayload = {
|
||||||
import type { OutlineNoteCard } from '../card/outline-card.js';
|
noteId: string;
|
||||||
|
};
|
||||||
/**
|
|
||||||
* start drag notes
|
|
||||||
* @param notes notes to drag
|
|
||||||
*/
|
|
||||||
export function startDragging(options: {
|
|
||||||
onDragEnd?: (insertIndex?: number) => void;
|
|
||||||
onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void;
|
|
||||||
outlineListContainer: HTMLElement;
|
|
||||||
document: Document;
|
|
||||||
host: Document | HTMLElement;
|
|
||||||
container: OutlinePanelBody;
|
|
||||||
doc: Store;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
document,
|
|
||||||
host,
|
|
||||||
container,
|
|
||||||
onDragMove,
|
|
||||||
onDragEnd,
|
|
||||||
outlineListContainer,
|
|
||||||
} = options;
|
|
||||||
const maskElement = createMaskElement(document);
|
|
||||||
const listContainerRect = outlineListContainer.getBoundingClientRect();
|
|
||||||
const children = Array.from(
|
|
||||||
outlineListContainer.children
|
|
||||||
) as OutlineNoteCard[];
|
|
||||||
let idx: undefined | number;
|
|
||||||
let indicatorTranslateY: undefined | number;
|
|
||||||
|
|
||||||
container.renderRoot.append(maskElement);
|
|
||||||
|
|
||||||
const insideListContainer = (e: MouseEvent) => {
|
|
||||||
return (
|
|
||||||
e.clientX >= listContainerRect.left &&
|
|
||||||
e.clientX <= listContainerRect.right &&
|
|
||||||
e.clientY >= listContainerRect.top &&
|
|
||||||
e.clientY <= listContainerRect.bottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const disposeMove = on(container, 'mousemove', e => {
|
|
||||||
if (!insideListContainer(e)) {
|
|
||||||
idx = undefined;
|
|
||||||
onDragMove?.(idx, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
idx = 0;
|
|
||||||
for (const card of children) {
|
|
||||||
if (!card.note || card.note.displayMode === NoteDisplayMode.EdgelessOnly)
|
|
||||||
break;
|
|
||||||
|
|
||||||
const topBoundary =
|
|
||||||
listContainerRect.top + card.offsetTop - outlineListContainer.scrollTop;
|
|
||||||
const midBoundary = topBoundary + card.offsetHeight / 2;
|
|
||||||
const bottomBoundary = topBoundary + card.offsetHeight;
|
|
||||||
|
|
||||||
if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) {
|
|
||||||
idx = e.clientY > midBoundary ? idx + 1 : idx;
|
|
||||||
|
|
||||||
indicatorTranslateY =
|
|
||||||
e.clientY > midBoundary ? bottomBoundary : topBoundary;
|
|
||||||
indicatorTranslateY -= listContainerRect.top;
|
|
||||||
|
|
||||||
onDragMove?.(idx, indicatorTranslateY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
++idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragMove?.(idx);
|
|
||||||
});
|
|
||||||
|
|
||||||
let ended = false;
|
|
||||||
const dragEnd = () => {
|
|
||||||
if (ended) return;
|
|
||||||
|
|
||||||
ended = true;
|
|
||||||
maskElement.remove();
|
|
||||||
|
|
||||||
disposeMove();
|
|
||||||
onDragEnd?.(idx);
|
|
||||||
};
|
|
||||||
|
|
||||||
once(host as Document, 'mouseup', dragEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMaskElement(doc: Document) {
|
|
||||||
const mask = doc.createElement('div');
|
|
||||||
|
|
||||||
mask.style.height = '100vh';
|
|
||||||
mask.style.width = '100vw';
|
|
||||||
mask.style.position = 'fixed';
|
|
||||||
mask.style.left = '0';
|
|
||||||
mask.style.top = '0';
|
|
||||||
mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)';
|
|
||||||
mask.style.cursor = 'grabbing';
|
|
||||||
|
|
||||||
return mask;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BlocksUtils,
|
BlocksUtils,
|
||||||
|
matchFlavours,
|
||||||
type NoteBlockModel,
|
type NoteBlockModel,
|
||||||
type NoteDisplayMode,
|
NoteDisplayMode,
|
||||||
type ParagraphBlockModel,
|
type ParagraphBlockModel,
|
||||||
type RootBlockModel,
|
type RootBlockModel,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
@@ -9,39 +10,24 @@ import type { BlockModel, Store } from '@blocksuite/store';
|
|||||||
|
|
||||||
import { headingKeys } from '../config.js';
|
import { headingKeys } from '../config.js';
|
||||||
|
|
||||||
type OutlineNoteItem = {
|
|
||||||
note: NoteBlockModel;
|
|
||||||
/**
|
|
||||||
* the index of the note inside its parent's children property
|
|
||||||
*/
|
|
||||||
index: number;
|
|
||||||
/**
|
|
||||||
* the number displayed on the outline panel
|
|
||||||
*/
|
|
||||||
number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getNotesFromDoc(
|
export function getNotesFromDoc(
|
||||||
doc: Store,
|
doc: Store,
|
||||||
modes: NoteDisplayMode[]
|
modes: NoteDisplayMode[] = [
|
||||||
): OutlineNoteItem[] {
|
NoteDisplayMode.DocAndEdgeless,
|
||||||
|
NoteDisplayMode.DocOnly,
|
||||||
|
NoteDisplayMode.EdgelessOnly,
|
||||||
|
]
|
||||||
|
) {
|
||||||
const rootModel = doc.root;
|
const rootModel = doc.root;
|
||||||
if (!rootModel) return [];
|
if (!rootModel) return [];
|
||||||
|
|
||||||
const notes: OutlineNoteItem[] = [];
|
const notes: NoteBlockModel[] = [];
|
||||||
|
|
||||||
rootModel.children.forEach((block, index) => {
|
rootModel.children.forEach(block => {
|
||||||
if (!['affine:note'].includes(block.flavour)) return;
|
if (!matchFlavours(block, ['affine:note'])) return;
|
||||||
|
|
||||||
const blockModel = block as NoteBlockModel;
|
if (modes.includes(block.displayMode$.value)) {
|
||||||
const OutlineNoteItem = {
|
notes.push(block);
|
||||||
note: block as NoteBlockModel,
|
|
||||||
index,
|
|
||||||
number: index + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (modes.includes(blockModel.displayMode$.value)) {
|
|
||||||
notes.push(OutlineNoteItem);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,11 +61,13 @@ export function getHeadingBlocksFromNote(
|
|||||||
|
|
||||||
export function getHeadingBlocksFromDoc(
|
export function getHeadingBlocksFromDoc(
|
||||||
doc: Store,
|
doc: Store,
|
||||||
modes: NoteDisplayMode[],
|
modes: NoteDisplayMode[] = [
|
||||||
|
NoteDisplayMode.DocAndEdgeless,
|
||||||
|
NoteDisplayMode.DocOnly,
|
||||||
|
NoteDisplayMode.EdgelessOnly,
|
||||||
|
],
|
||||||
ignoreEmpty = false
|
ignoreEmpty = false
|
||||||
) {
|
) {
|
||||||
const notes = getNotesFromDoc(doc, modes);
|
const notes = getNotesFromDoc(doc, modes);
|
||||||
return notes
|
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
|
||||||
.map(({ note }) => getHeadingBlocksFromNote(note, ignoreEmpty))
|
|
||||||
.flat();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export async function assertVisibleBlockCount(
|
|||||||
// not only count, but also check if all the blocks are visible
|
// not only count, but also check if all the blocks are visible
|
||||||
const locator = page.locator(`affine-${flavour}`);
|
const locator = page.locator(`affine-${flavour}`);
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < (await locator.count()); i++) {
|
||||||
if (await locator.nth(i).isVisible()) {
|
if (await locator.nth(i).isVisible()) {
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
clickView,
|
clickView,
|
||||||
createEdgelessNoteBlock,
|
createEdgelessNoteBlock,
|
||||||
focusDocTitle,
|
focusDocTitle,
|
||||||
|
getEdgelessSelectedIds,
|
||||||
locateElementToolbar,
|
locateElementToolbar,
|
||||||
} from '@affine-test/kit/utils/editor';
|
} from '@affine-test/kit/utils/editor';
|
||||||
import {
|
import {
|
||||||
pressBackspace,
|
pressBackspace,
|
||||||
pressEnter,
|
pressEnter,
|
||||||
|
pressEscape,
|
||||||
selectAllByKeyboard,
|
selectAllByKeyboard,
|
||||||
} from '@affine-test/kit/utils/keyboard';
|
} from '@affine-test/kit/utils/keyboard';
|
||||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
@@ -50,241 +52,342 @@ function getTocHeading(panel: Locator, level: number) {
|
|||||||
// ! Please note that when any card mode changed, the locator will be mutated
|
// ! Please note that when any card mode changed, the locator will be mutated
|
||||||
function locateCards(toc: Locator, mode?: 'both' | 'page' | 'edgeless') {
|
function locateCards(toc: Locator, mode?: 'both' | 'page' | 'edgeless') {
|
||||||
const cards = toc.locator('affine-outline-note-card');
|
const cards = toc.locator('affine-outline-note-card');
|
||||||
return mode ? cards.locator(`[data-visibility="${mode}"]`) : cards;
|
return mode
|
||||||
|
? cards.locator(`>div[data-visibility="${mode}"]`)
|
||||||
|
: cards.locator('>div');
|
||||||
}
|
}
|
||||||
|
|
||||||
function locateSortingButton(panel: Locator) {
|
function locateSortingButton(panel: Locator) {
|
||||||
return panel.getByTestId('toggle-notes-sorting-button');
|
return panel.getByTestId('toggle-notes-sorting-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeNoteDisplayMode(
|
||||||
|
card: Locator,
|
||||||
|
mode: 'both' | 'doc' | 'edgeless'
|
||||||
|
) {
|
||||||
|
await card.hover();
|
||||||
|
await card.getByTestId('display-mode-button').click();
|
||||||
|
await card.locator(`note-display-mode-panel .item.${mode}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await openHomePage(page);
|
await openHomePage(page);
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await waitForEditorLoad(page);
|
await waitForEditorLoad(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display title and headings when there are non-empty headings in editor', async ({
|
test.describe('TOC display', () => {
|
||||||
page,
|
test('should display title and headings when there are non-empty headings in editor', async ({
|
||||||
}) => {
|
page,
|
||||||
await createTitle(page);
|
}) => {
|
||||||
await createHeadings(page);
|
await createTitle(page);
|
||||||
|
await createHeadings(page);
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
const toc = await openTocPanel(page);
|
||||||
|
|
||||||
await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible();
|
await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible();
|
||||||
for (let i = 1; i <= 6; i++) {
|
for (let i = 1; i <= 6; i++) {
|
||||||
await expect(getTocHeading(toc, i)).toBeVisible();
|
await expect(getTocHeading(toc, i)).toBeVisible();
|
||||||
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`);
|
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display placeholder when no headings', async ({ page }) => {
|
test('should display placeholder when no headings', async ({ page }) => {
|
||||||
const toc = await openTocPanel(page);
|
const toc = await openTocPanel(page);
|
||||||
const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder');
|
const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder');
|
||||||
|
|
||||||
await createTitle(page);
|
await createTitle(page);
|
||||||
await pressEnter(page);
|
|
||||||
await type(page, 'hello world');
|
|
||||||
|
|
||||||
await expect(noHeadingPlaceholder).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not display headings when there are only empty headings', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await createTitle(page);
|
|
||||||
|
|
||||||
// create empty headings
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
|
||||||
await type(page, `${'#'.repeat(i)} `);
|
|
||||||
await pressEnter(page);
|
await pressEnter(page);
|
||||||
}
|
await type(page, 'hello world');
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
await expect(noHeadingPlaceholder).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
|
test('should not display headings when there are only empty headings', async ({
|
||||||
for (let i = 1; i <= 6; i++) {
|
page,
|
||||||
await expect(getTocHeading(toc, i)).toBeHidden();
|
}) => {
|
||||||
}
|
await createTitle(page);
|
||||||
});
|
|
||||||
|
|
||||||
test('should update panel when modify or clear title or headings', async ({
|
// create empty headings
|
||||||
page,
|
for (let i = 1; i <= 6; i++) {
|
||||||
}) => {
|
await type(page, `${'#'.repeat(i)} `);
|
||||||
const title = await createTitle(page);
|
await pressEnter(page);
|
||||||
const headings = await createHeadings(page);
|
}
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
const toc = await openTocPanel(page);
|
||||||
|
|
||||||
await title.scrollIntoViewIfNeeded();
|
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
|
||||||
await title.click();
|
for (let i = 1; i <= 6; i++) {
|
||||||
await type(page, 'xxx');
|
await expect(getTocHeading(toc, i)).toBeHidden();
|
||||||
await expect(toc.getByTestId('outline-block-preview-title')).toContainText([
|
}
|
||||||
'Titlexxx',
|
});
|
||||||
]);
|
|
||||||
await selectAllByKeyboard(page);
|
|
||||||
await pressBackspace(page);
|
|
||||||
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
|
|
||||||
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
test('should update panel when modify or clear title or headings', async ({
|
||||||
await headings[i - 1].click();
|
page,
|
||||||
|
}) => {
|
||||||
|
const title = await createTitle(page);
|
||||||
|
const headings = await createHeadings(page);
|
||||||
|
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
|
||||||
|
await title.scrollIntoViewIfNeeded();
|
||||||
|
await title.click();
|
||||||
await type(page, 'xxx');
|
await type(page, 'xxx');
|
||||||
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}xxx`);
|
await expect(toc.getByTestId('outline-block-preview-title')).toContainText([
|
||||||
|
'Titlexxx',
|
||||||
|
]);
|
||||||
await selectAllByKeyboard(page);
|
await selectAllByKeyboard(page);
|
||||||
await pressBackspace(page);
|
await pressBackspace(page);
|
||||||
await expect(getTocHeading(toc, i)).toBeHidden();
|
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should update panel when switch doc', async ({ page }) => {
|
for (let i = 1; i <= 6; i++) {
|
||||||
const toc = await openTocPanel(page);
|
await headings[i - 1].click();
|
||||||
await focusDocTitle(page);
|
await type(page, 'xxx');
|
||||||
await page.keyboard.press('ArrowDown');
|
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}xxx`);
|
||||||
await type(page, '# Heading 1');
|
await selectAllByKeyboard(page);
|
||||||
|
await pressBackspace(page);
|
||||||
|
await expect(getTocHeading(toc, i)).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await clickNewPageButton(page);
|
test('should update panel when switch doc', async ({ page }) => {
|
||||||
await expect(getTocHeading(toc, 1)).toBeHidden();
|
const toc = await openTocPanel(page);
|
||||||
await page.goBack();
|
await focusDocTitle(page);
|
||||||
await expect(getTocHeading(toc, 1)).toBeVisible();
|
await page.keyboard.press('ArrowDown');
|
||||||
});
|
await type(page, '# Heading 1');
|
||||||
|
|
||||||
test('should add padding to sub-headings', async ({ page }) => {
|
await clickNewPageButton(page);
|
||||||
await createHeadings(page);
|
await expect(getTocHeading(toc, 1)).toBeHidden();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(getTocHeading(toc, 1)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
test('should add padding to sub-headings', async ({ page }) => {
|
||||||
|
await createHeadings(page);
|
||||||
|
|
||||||
let prev = getTocHeading(toc, 1);
|
const toc = await openTocPanel(page);
|
||||||
for (let i = 2; i <= 6; i++) {
|
|
||||||
const curr = getTocHeading(toc, i);
|
|
||||||
|
|
||||||
const prevRect = await prev.boundingBox();
|
let prev = getTocHeading(toc, 1);
|
||||||
const currRect = await curr.boundingBox();
|
for (let i = 2; i <= 6; i++) {
|
||||||
|
const curr = getTocHeading(toc, i);
|
||||||
|
|
||||||
expect(prevRect).not.toBeNull();
|
const prevRect = await prev.boundingBox();
|
||||||
expect(currRect).not.toBeNull();
|
const currRect = await curr.boundingBox();
|
||||||
|
|
||||||
expect(prevRect!.x).toBeLessThan(currRect!.x);
|
expect(prevRect).not.toBeNull();
|
||||||
prev = curr;
|
expect(currRect).not.toBeNull();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should highlight heading when scroll to area before viewport center', async ({
|
expect(prevRect!.x).toBeLessThan(currRect!.x);
|
||||||
page,
|
prev = curr;
|
||||||
}) => {
|
}
|
||||||
const title = await createTitle(page);
|
});
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
|
test('visibility sorting should be enabled in edgeless mode and disabled in page mode by default, and can be changed', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await pressEnter(page);
|
await pressEnter(page);
|
||||||
}
|
await type(page, '# Heading 1');
|
||||||
const headings = await createHeadings(page, 10);
|
|
||||||
await title.scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
const toc = await openTocPanel(page);
|
||||||
|
const sortingButton = locateSortingButton(toc);
|
||||||
|
await expect(sortingButton).not.toHaveClass(/active/);
|
||||||
|
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
|
||||||
|
|
||||||
const viewportCenter = await getVerticalCenterFromLocator(
|
await clickEdgelessModeButton(page);
|
||||||
page.locator('body')
|
await expect(sortingButton).toHaveClass(/active/);
|
||||||
);
|
expect(toc.locator('[data-sortable="true"]')).toHaveCount(1);
|
||||||
|
|
||||||
const activeHeadingContainer = toc.locator(
|
await sortingButton.click();
|
||||||
'affine-outline-panel-body .active'
|
await expect(sortingButton).not.toHaveClass(/active/);
|
||||||
);
|
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
await title.click();
|
test('should notify user when there are some page only notes and sorting is disabled', async ({
|
||||||
await expect(activeHeadingContainer).toContainText('Title');
|
page,
|
||||||
|
}) => {
|
||||||
|
await clickEdgelessModeButton(page);
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
await createEdgelessNoteBlock(page, [100, 100]);
|
||||||
|
const card = locateCards(toc, 'edgeless');
|
||||||
|
await changeNoteDisplayMode(card, 'doc');
|
||||||
|
|
||||||
for (let i = 0; i < headings.length; i++) {
|
const notification = toc.getByTestId('affine-outline-notice');
|
||||||
const lastHeadingCenter = await getVerticalCenterFromLocator(headings[i]);
|
await expect(notification).toBeHidden();
|
||||||
await page.mouse.wheel(0, lastHeadingCenter - viewportCenter + 20);
|
|
||||||
await page.waitForTimeout(10);
|
|
||||||
|
|
||||||
await expect(activeHeadingContainer).toContainText(`Heading ${i + 1}`);
|
const sortingButton = locateSortingButton(toc);
|
||||||
}
|
await sortingButton.click();
|
||||||
|
await expect(notification).toBeVisible();
|
||||||
|
|
||||||
|
await notification.getByTestId('outline-notice-sort-button').click();
|
||||||
|
await expect(notification).toBeHidden();
|
||||||
|
await expect(sortingButton).toHaveClass(/active/);
|
||||||
|
|
||||||
|
await sortingButton.click();
|
||||||
|
await expect(notification).toBeVisible();
|
||||||
|
await notification.getByTestId('outline-notice-close-button').click();
|
||||||
|
await expect(notification).toBeHidden();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should scroll to heading and highlight heading when click item in outline panel', async ({
|
test.describe('TOC and editor scroll', () => {
|
||||||
page,
|
test('should highlight heading when scroll to area before viewport center', async ({
|
||||||
}) => {
|
page,
|
||||||
const headings = await createHeadings(page, 10);
|
}) => {
|
||||||
const toc = await openTocPanel(page);
|
const title = await createTitle(page);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await pressEnter(page);
|
||||||
|
}
|
||||||
|
const headings = await createHeadings(page, 10);
|
||||||
|
await title.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
const activeHeadingContainer = toc.locator(
|
const toc = await openTocPanel(page);
|
||||||
'affine-outline-panel-body .active'
|
|
||||||
);
|
|
||||||
|
|
||||||
const headingsInPanel = Array.from({ length: 6 }, (_, i) =>
|
const viewportCenter = await getVerticalCenterFromLocator(
|
||||||
getTocHeading(toc, i + 1)
|
page.locator('body')
|
||||||
);
|
);
|
||||||
|
|
||||||
await headingsInPanel[2].click();
|
const activeHeadingContainer = toc.locator(
|
||||||
await expect(headings[2]).toBeVisible();
|
'affine-outline-panel-body .active'
|
||||||
await expect(activeHeadingContainer).toContainText('Heading 3');
|
);
|
||||||
|
|
||||||
|
await title.click();
|
||||||
|
await expect(activeHeadingContainer).toContainText('Title');
|
||||||
|
|
||||||
|
for (let i = 0; i < headings.length; i++) {
|
||||||
|
const lastHeadingCenter = await getVerticalCenterFromLocator(headings[i]);
|
||||||
|
await page.mouse.wheel(0, lastHeadingCenter - viewportCenter + 20);
|
||||||
|
await page.waitForTimeout(10);
|
||||||
|
|
||||||
|
await expect(activeHeadingContainer).toContainText(`Heading ${i + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should scroll to heading and highlight heading when click item in outline panel', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const headings = await createHeadings(page, 10);
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
|
||||||
|
const activeHeadingContainer = toc.locator(
|
||||||
|
'affine-outline-panel-body .active'
|
||||||
|
);
|
||||||
|
|
||||||
|
const headingsInPanel = Array.from({ length: 6 }, (_, i) =>
|
||||||
|
getTocHeading(toc, i + 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
await headingsInPanel[2].click();
|
||||||
|
await expect(headings[2]).toBeVisible();
|
||||||
|
await expect(activeHeadingContainer).toContainText('Heading 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should scroll to title when click title in outline panel', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const title = await createTitle(page);
|
||||||
|
await pressEnter(page);
|
||||||
|
await createHeadings(page, 10);
|
||||||
|
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
|
||||||
|
const titleInPanel = toc.getByTestId('outline-block-preview-title');
|
||||||
|
|
||||||
|
await expect(title).not.toBeInViewport();
|
||||||
|
await titleInPanel.click();
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should scroll to title when click title in outline panel', async ({
|
test.describe('TOC and edgeless selection', () => {
|
||||||
page,
|
test('should select note blocks when selecting cards in TOC', async ({
|
||||||
}) => {
|
page,
|
||||||
const title = await createTitle(page);
|
}) => {
|
||||||
await pressEnter(page);
|
const toc = await openTocPanel(page);
|
||||||
await createHeadings(page, 10);
|
await clickEdgelessModeButton(page);
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
const cards = locateCards(toc);
|
||||||
|
|
||||||
const titleInPanel = toc.getByTestId('outline-block-preview-title');
|
await createEdgelessNoteBlock(page, [100, 100]);
|
||||||
|
await changeNoteDisplayMode(cards.last(), 'doc');
|
||||||
|
await createEdgelessNoteBlock(page, [200, 200]);
|
||||||
|
|
||||||
await expect(title).not.toBeInViewport();
|
await cards.nth(0).click();
|
||||||
await titleInPanel.click();
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
|
||||||
await expect(title).toBeVisible();
|
await cards.nth(0).click();
|
||||||
});
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(0);
|
||||||
|
|
||||||
test('visibility sorting should be enabled in edgeless mode and disabled in page mode by default, and can be changed', async ({
|
await cards.nth(1).click();
|
||||||
page,
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
|
||||||
}) => {
|
await cards.nth(1).click();
|
||||||
await pressEnter(page);
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(0);
|
||||||
await type(page, '# Heading 1');
|
|
||||||
|
|
||||||
const toc = await openTocPanel(page);
|
await cards.nth(2).click();
|
||||||
const sortingButton = locateSortingButton(toc);
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(1);
|
||||||
await expect(sortingButton).not.toHaveClass(/active/);
|
await cards.nth(2).click();
|
||||||
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(0);
|
||||||
|
|
||||||
await clickEdgelessModeButton(page);
|
await cards.nth(0).click({ modifiers: ['Shift'] });
|
||||||
await expect(sortingButton).toHaveClass(/active/);
|
await cards.nth(1).click({ modifiers: ['Shift'] });
|
||||||
expect(toc.locator('[data-sortable="true"]')).toHaveCount(1);
|
await cards.nth(2).click({ modifiers: ['Shift'] });
|
||||||
|
|
||||||
await sortingButton.click();
|
expect(await getEdgelessSelectedIds(page)).toHaveLength(3);
|
||||||
await expect(sortingButton).not.toHaveClass(/active/);
|
});
|
||||||
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
|
|
||||||
|
test('should select note cards when select note blocks in canvas', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await clickEdgelessModeButton(page);
|
||||||
|
await selectAllByKeyboard(page);
|
||||||
|
await pressBackspace(page);
|
||||||
|
await createEdgelessNoteBlock(page, [100, 100]);
|
||||||
|
await createEdgelessNoteBlock(page, [200, 200]);
|
||||||
|
await clickView(page, [0, 0]);
|
||||||
|
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
const cards = locateCards(toc);
|
||||||
|
|
||||||
|
await clickView(page, [100, 100]);
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-status', 'selected');
|
||||||
|
|
||||||
|
await clickView(page, [200, 200]);
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('data-status', 'selected');
|
||||||
|
|
||||||
|
await selectAllByKeyboard(page);
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-status', 'selected');
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('data-status', 'selected');
|
||||||
|
|
||||||
|
await pressEscape(page);
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-status', 'normal');
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('data-status', 'normal');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('drag and drop note in outline panel', () => {
|
test.describe('drag and drop note in outline panel', () => {
|
||||||
async function changeNoteDisplayMode(
|
const dragCard = async (
|
||||||
card: Locator,
|
from: Locator,
|
||||||
mode: 'both' | 'doc' | 'edgeless'
|
to: Locator,
|
||||||
) {
|
|
||||||
await card.hover();
|
|
||||||
await card.getByTestId('display-mode-button').click();
|
|
||||||
await card.locator(`note-display-mode-panel .item.${mode}`).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dragNoteCard(
|
|
||||||
page: Page,
|
|
||||||
fromCard: Locator,
|
|
||||||
toCard: Locator,
|
|
||||||
position: 'before' | 'after' = 'before'
|
position: 'before' | 'after' = 'before'
|
||||||
) {
|
) => {
|
||||||
const fromRect = await fromCard.boundingBox();
|
const fromRect = await from.boundingBox();
|
||||||
const toRect = await toCard.boundingBox();
|
const fromCenter = {
|
||||||
|
x: fromRect!.width / 2,
|
||||||
|
y: fromRect!.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
await page.mouse.move(fromRect!.x + 10, fromRect!.y + 10);
|
const toRect = await to.boundingBox();
|
||||||
await page.mouse.down();
|
const toCenter = {
|
||||||
if (position === 'before') {
|
x: toRect!.width / 2,
|
||||||
await page.mouse.move(toRect!.x + 5, toRect!.y + 5, { steps: 20 });
|
y: toRect!.height / 2,
|
||||||
} else {
|
};
|
||||||
await page.mouse.move(toRect!.x + 5, toRect!.y + toRect!.height - 5, {
|
|
||||||
steps: 20,
|
await from.dragTo(to, {
|
||||||
});
|
sourcePosition: fromCenter,
|
||||||
}
|
targetPosition:
|
||||||
await page.mouse.up();
|
position === 'before'
|
||||||
}
|
? { x: toCenter.x, y: toCenter.y - 10 }
|
||||||
|
: { x: toCenter.x, y: toCenter.y + 10 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// create 2 both cards, 2 page cards and 2 edgeless cards
|
// create 2 both cards, 2 page cards and 2 edgeless cards
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -327,7 +430,7 @@ test.describe('drag and drop note in outline panel', () => {
|
|||||||
const toc = await openTocPanel(page);
|
const toc = await openTocPanel(page);
|
||||||
const cards = locateCards(toc);
|
const cards = locateCards(toc);
|
||||||
|
|
||||||
await dragNoteCard(page, cards.nth(3), cards.nth(1));
|
await dragCard(cards.nth(3), cards.nth(1));
|
||||||
|
|
||||||
await clickPageModeButton(page);
|
await clickPageModeButton(page);
|
||||||
const paragraphs = page
|
const paragraphs = page
|
||||||
@@ -341,13 +444,36 @@ test.describe('drag and drop note in outline panel', () => {
|
|||||||
|
|
||||||
// Note card should be able to drag and drop in page mode
|
// Note card should be able to drag and drop in page mode
|
||||||
await locateSortingButton(toc).click();
|
await locateSortingButton(toc).click();
|
||||||
await dragNoteCard(page, cards.nth(3), cards.nth(1));
|
await dragCard(cards.nth(3), cards.nth(1));
|
||||||
|
|
||||||
await expect(paragraphs.nth(0)).toContainText('0');
|
await expect(paragraphs.nth(0)).toContainText('0');
|
||||||
await expect(paragraphs.nth(1)).toContainText('2');
|
await expect(paragraphs.nth(1)).toContainText('2');
|
||||||
await expect(paragraphs.nth(2)).toContainText('3');
|
await expect(paragraphs.nth(2)).toContainText('3');
|
||||||
await expect(paragraphs.nth(3)).toContainText('1');
|
await expect(paragraphs.nth(3)).toContainText('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('multiple selected note cards can be dragged and dropped at same time', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const toc = await openTocPanel(page);
|
||||||
|
const cards = locateCards(toc);
|
||||||
|
|
||||||
|
await cards.nth(2).click({ modifiers: ['Shift'] });
|
||||||
|
await cards.nth(3).click({ modifiers: ['Shift'] });
|
||||||
|
|
||||||
|
await dragCard(cards.nth(2), cards.nth(0));
|
||||||
|
await clickPageModeButton(page);
|
||||||
|
|
||||||
|
const paragraphs = page
|
||||||
|
.locator('affine-paragraph')
|
||||||
|
.locator('[data-v-text="true"]');
|
||||||
|
|
||||||
|
await expect(paragraphs).toHaveCount(4);
|
||||||
|
await expect(paragraphs.nth(0)).toContainText('2');
|
||||||
|
await expect(paragraphs.nth(1)).toContainText('3');
|
||||||
|
await expect(paragraphs.nth(2)).toContainText('0');
|
||||||
|
await expect(paragraphs.nth(3)).toContainText('1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('advanced visibility control', () => {
|
test.describe('advanced visibility control', () => {
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ export async function pressBackspace(page: Page, count = 1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pressEscape(page: Page, count = 1) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await page.keyboard.press('Escape', { delay: 50 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyByKeyboard(page: Page) {
|
export async function copyByKeyboard(page: Page) {
|
||||||
await keyDownCtrlOrMeta(page);
|
await keyDownCtrlOrMeta(page);
|
||||||
await page.keyboard.press('c', { delay: 50 });
|
await page.keyboard.press('c', { delay: 50 });
|
||||||
|
|||||||
@@ -3551,7 +3551,7 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note":
|
"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:^, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note"
|
resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4064,6 +4064,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@blocksuite/presets@workspace:blocksuite/presets"
|
resolution: "@blocksuite/presets@workspace:blocksuite/presets"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@blocksuite/affine-block-note": "workspace:^"
|
||||||
"@blocksuite/affine-block-surface": "workspace:*"
|
"@blocksuite/affine-block-surface": "workspace:*"
|
||||||
"@blocksuite/affine-model": "workspace:*"
|
"@blocksuite/affine-model": "workspace:*"
|
||||||
"@blocksuite/affine-shared": "workspace:*"
|
"@blocksuite/affine-shared": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user