refactor(editor): refactor page note empty checker (#9570)

Close [BS-2320](https://linear.app/affine-design/issue/BS-2320/内容为空的状态判断)
This commit is contained in:
L-Sun
2025-01-07 11:24:40 +00:00
parent 482b534a90
commit 440239809c
11 changed files with 187 additions and 18 deletions

View File

@@ -111,6 +111,17 @@ export class NoteBlockModel
if (!this._isSelectable()) return false; if (!this._isSelectable()) return false;
return super.intersectsBound(bound); return super.intersectsBound(bound);
} }
override isEmpty(): boolean {
if (this.children.length === 0) return true;
if (this.children.length === 1) {
const firstChild = this.children[0];
if (firstChild.flavour === 'affine:paragraph') {
return firstChild.isEmpty();
}
}
return false;
}
} }
declare global { declare global {

View File

@@ -41,6 +41,10 @@ export class ParagraphBlockModel extends BlockModel<ParagraphProps> {
override flavour!: 'affine:paragraph'; override flavour!: 'affine:paragraph';
override text!: Text; override text!: Text;
override isEmpty(): boolean {
return this.text$.value.length === 0 && this.children.length === 0;
}
} }
declare global { declare global {

View File

@@ -22,6 +22,22 @@ export class RootBlockModel extends BlockModel<RootBlockProps> {
}); });
}); });
} }
/**
* A page is empty if it only contains one empty note and the canvas is empty
*/
override isEmpty() {
let numNotes = 0;
let empty = true;
for (const child of this.children) {
empty = empty && child.isEmpty();
if (child.flavour === 'affine:note') numNotes++;
if (numNotes > 1) return false;
}
return empty;
}
} }
export const RootBlockSchema = defineBlockSchema({ export const RootBlockSchema = defineBlockSchema({

View File

@@ -33,7 +33,7 @@ export function getDropRectByPoint(
} }
let bounds = table.getBoundingClientRect(); let bounds = table.getBoundingClientRect();
if (model.isEmpty.value) { if (model.children.length === 0) {
result.flag = DropFlags.EmptyDatabase; result.flag = DropFlags.EmptyDatabase;
if (point.y < bounds.top) return result; if (point.y < bounds.top) return result;

View File

@@ -112,6 +112,10 @@ export class PageRootBlockComponent extends BlockComponent<
clipboardController = new PageClipboard(this); clipboardController = new PageClipboard(this);
/**
* Focus the first paragraph in the default note block.
* If there is no paragraph, create one.
*/
focusFirstParagraph = () => { focusFirstParagraph = () => {
const defaultNote = this._getDefaultNoteBlock(); const defaultNote = this._getDefaultNoteBlock();
const firstText = defaultNote?.children.find(block => const firstText = defaultNote?.children.find(block =>

View File

@@ -103,6 +103,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return Object.keys(this._elementCtorMap); return Object.keys(this._elementCtorMap);
} }
override isEmpty(): boolean {
return this._elementModels.size === 0 && this.children.length === 0;
}
constructor() { constructor() {
super(); super();
this.created.once(() => this._init()); this.created.once(() => this._init());

View File

@@ -70,9 +70,9 @@ export class BlockModel<
id!: string; id!: string;
isEmpty = computed(() => { isEmpty() {
return this._children.value.length === 0; return this.children.length === 0;
}); }
keys!: string[]; keys!: string[];

View File

@@ -192,7 +192,7 @@ export class Blocks {
} }
get isEmpty() { get isEmpty() {
return Object.values(this._blocks.peek()).length === 0; return this.root?.isEmpty() ?? true;
} }
get loaded() { get loaded() {

View File

@@ -1,6 +1,8 @@
import { beforeEach, expect, test } from 'vitest'; import { LocalShapeElementModel } from '@blocksuite/affine-model';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { getSurface } from '../utils/edgeless.js'; import { addNote, getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js'; import { setupEditor } from '../utils/setup.js';
beforeEach(async () => { beforeEach(async () => {
@@ -16,3 +18,136 @@ test('basic assert', () => {
expect(getSurface(window.doc, window.editor)).toBeDefined(); expect(getSurface(window.doc, window.editor)).toBeDefined();
}); });
describe('doc / note empty checker', () => {
test('a paragraph is empty if it dose not contain text and child blocks', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
const paragraph = doc.getBlock(paragraphId)?.model;
expect(paragraph?.isEmpty()).toBe(true);
});
test('a paragraph is not empty if it contains text', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock(
'affine:paragraph',
{
text: new Text('hello'),
},
noteId
);
const paragraph = doc.getBlock(paragraphId)?.model;
expect(paragraph?.isEmpty()).toBe(false);
});
test('a paragraph is not empty if it contains children blocks', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
const paragraph = doc.getBlock(paragraphId)?.model;
// sub paragraph
doc.addBlock('affine:paragraph', {}, paragraphId);
expect(paragraph?.isEmpty()).toBe(false);
});
test('a note is empty if it dose not contain any blocks', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
note.children.forEach(child => {
doc.deleteBlock(child);
});
expect(note.children.length).toBe(0);
expect(note.isEmpty()).toBe(true);
});
test('a note is empty if it only contains a empty paragraph', () => {
// `addNote` will create a empty paragraph
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
expect(note.isEmpty()).toBe(true);
});
test('a note is not empty if it contains multi blocks', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
doc.addBlock('affine:paragraph', {}, noteId);
expect(note.isEmpty()).toBe(false);
});
test('a surface is empty if it dose not contains any element or blocks', () => {
const surface = getSurface(doc, editor).model;
expect(surface.isEmpty()).toBe(true);
const shapeId = surface.addElement({
type: 'shape',
});
expect(surface.isEmpty()).toBe(false);
surface.deleteElement(shapeId);
expect(surface.isEmpty()).toBe(true);
const frameId = doc.addBlock('affine:frame', {}, surface.id);
const frame = doc.getBlock(frameId)!.model;
expect(surface.isEmpty()).toBe(false);
doc.deleteBlock(frame);
expect(surface.isEmpty()).toBe(true);
});
test('a surface is empty if it only contains local elements', () => {
const surface = getSurface(doc, editor).model;
const localShape = new LocalShapeElementModel(surface);
surface.addLocalElement(localShape);
expect(surface.isEmpty()).toBe(true);
});
test('a just initialized doc is empty', () => {
expect(doc.isEmpty).toBe(true);
expect(editor.rootModel.isEmpty()).toBe(true);
});
test('a doc is empty if it only contains a note', () => {
addNote(doc);
expect(doc.isEmpty).toBe(true);
addNote(doc);
expect(
doc.isEmpty,
'a doc is not empty if it contains multi-notes'
).toBeFalsy();
});
test('a note is empty if its children array is empty', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
note?.children.forEach(child => doc.deleteBlock(child));
expect(note?.children.length === 0).toBe(true);
expect(note?.isEmpty()).toBe(true);
});
test('a doc is empty if its only contains an empty note and an empty surface', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
expect(doc.isEmpty).toBe(true);
const newNoteId = addNote(doc);
const newNote = doc.getBlock(newNoteId)!.model;
expect(doc.isEmpty).toBe(false);
doc.deleteBlock(newNote);
expect(doc.isEmpty).toBe(true);
const newParagraphId = doc.addBlock('affine:paragraph', {}, note);
const newParagraph = doc.getBlock(newParagraphId)!.model;
expect(doc.isEmpty).toBe(false);
doc.deleteBlock(newParagraph);
expect(doc.isEmpty).toBe(true);
const surface = getSurface(doc, editor).model;
expect(doc.isEmpty).toBe(true);
const shapeId = surface.addElement({
type: 'shape',
});
expect(doc.isEmpty).toBe(false);
surface.deleteElement(shapeId);
expect(doc.isEmpty).toBe(true);
});
});

View File

@@ -61,9 +61,8 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
private readonly _onTitleKeyDown = (event: KeyboardEvent) => { private readonly _onTitleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.doc.readonly) return; if (event.isComposing || this.doc.readonly) return;
const hasContent = !this.doc.isEmpty;
if (event.key === 'Enter' && hasContent && !event.isComposing) { if (event.key === 'Enter' && this._pageRoot) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -73,10 +72,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
const rightText = this._rootModel.title.split(inlineRange.index); const rightText = this._rootModel.title.split(inlineRange.index);
this._pageRoot.prependParagraphWithText(rightText); this._pageRoot.prependParagraphWithText(rightText);
} }
} else if (event.key === 'ArrowDown' && hasContent) { } else if (event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this._pageRoot.focusFirstParagraph(); this._pageRoot?.focusFirstParagraph();
} else if (event.key === 'Tab') { } else if (event.key === 'Tab') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -94,9 +93,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
} }
private get _pageRoot() { private get _pageRoot() {
const pageRoot = this._viewport.querySelector('affine-page-root'); return this._viewport.querySelector('affine-page-root');
assertExists(pageRoot);
return pageRoot;
} }
private get _rootModel() { private get _rootModel() {

View File

@@ -9,7 +9,7 @@ import {
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel, Blocks } from '@blocksuite/store'; import type { BlockModel, Blocks } from '@blocksuite/store';
import { baseTheme } from '@toeverything/theme'; import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; import { css, html, LitElement, unsafeCSS } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -286,7 +286,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
} }
} }
override firstUpdated() { override updated() {
this._displayModePopper = createButtonPopper( this._displayModePopper = createButtonPopper(
this._displayModeButtonGroup, this._displayModeButtonGroup,
this._displayModePanel, this._displayModePanel,
@@ -303,8 +303,6 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
} }
override render() { override render() {
if (this.note.isEmpty.peek()) return nothing;
const { children, displayMode } = this.note; const { children, displayMode } = this.note;
const currentMode = this._getCurrentModeLabel(displayMode); const currentMode = this._getCurrentModeLabel(displayMode);
const cardHeaderClasses = classMap({ const cardHeaderClasses = classMap({