diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts index f9bb82e517..cc554d4c8a 100644 --- a/blocksuite/affine/model/src/blocks/note/note-model.ts +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -111,6 +111,17 @@ export class NoteBlockModel if (!this._isSelectable()) return false; 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 { diff --git a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts index c467c48bca..f30b642b03 100644 --- a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts +++ b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts @@ -41,6 +41,10 @@ export class ParagraphBlockModel extends BlockModel { override flavour!: 'affine:paragraph'; override text!: Text; + + override isEmpty(): boolean { + return this.text$.value.length === 0 && this.children.length === 0; + } } declare global { diff --git a/blocksuite/affine/model/src/blocks/root/root-block-model.ts b/blocksuite/affine/model/src/blocks/root/root-block-model.ts index ca61212e52..2cf068a9fa 100644 --- a/blocksuite/affine/model/src/blocks/root/root-block-model.ts +++ b/blocksuite/affine/model/src/blocks/root/root-block-model.ts @@ -22,6 +22,22 @@ export class RootBlockModel extends BlockModel { }); }); } + + /** + * 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({ diff --git a/blocksuite/affine/shared/src/utils/dnd/get-drop-rect-by-point.ts b/blocksuite/affine/shared/src/utils/dnd/get-drop-rect-by-point.ts index 567057bf47..35977ba5da 100644 --- a/blocksuite/affine/shared/src/utils/dnd/get-drop-rect-by-point.ts +++ b/blocksuite/affine/shared/src/utils/dnd/get-drop-rect-by-point.ts @@ -33,7 +33,7 @@ export function getDropRectByPoint( } let bounds = table.getBoundingClientRect(); - if (model.isEmpty.value) { + if (model.children.length === 0) { result.flag = DropFlags.EmptyDatabase; if (point.y < bounds.top) return result; diff --git a/blocksuite/blocks/src/root-block/page/page-root-block.ts b/blocksuite/blocks/src/root-block/page/page-root-block.ts index 2da800b5f6..5ebcae60c9 100644 --- a/blocksuite/blocks/src/root-block/page/page-root-block.ts +++ b/blocksuite/blocks/src/root-block/page/page-root-block.ts @@ -112,6 +112,10 @@ export class PageRootBlockComponent extends BlockComponent< clipboardController = new PageClipboard(this); + /** + * Focus the first paragraph in the default note block. + * If there is no paragraph, create one. + */ focusFirstParagraph = () => { const defaultNote = this._getDefaultNoteBlock(); const firstText = defaultNote?.children.find(block => diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts index ef7126d15f..b7d058862c 100644 --- a/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts +++ b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts @@ -103,6 +103,10 @@ export class SurfaceBlockModel extends BlockModel { return Object.keys(this._elementCtorMap); } + override isEmpty(): boolean { + return this._elementModels.size === 0 && this.children.length === 0; + } + constructor() { super(); this.created.once(() => this._init()); diff --git a/blocksuite/framework/store/src/model/block/block-model.ts b/blocksuite/framework/store/src/model/block/block-model.ts index 61f86ac006..0b0c073915 100644 --- a/blocksuite/framework/store/src/model/block/block-model.ts +++ b/blocksuite/framework/store/src/model/block/block-model.ts @@ -70,9 +70,9 @@ export class BlockModel< id!: string; - isEmpty = computed(() => { - return this._children.value.length === 0; - }); + isEmpty() { + return this.children.length === 0; + } keys!: string[]; diff --git a/blocksuite/framework/store/src/model/blocks/blocks.ts b/blocksuite/framework/store/src/model/blocks/blocks.ts index a0f20f4358..55d2f77e5a 100644 --- a/blocksuite/framework/store/src/model/blocks/blocks.ts +++ b/blocksuite/framework/store/src/model/blocks/blocks.ts @@ -192,7 +192,7 @@ export class Blocks { } get isEmpty() { - return Object.values(this._blocks.peek()).length === 0; + return this.root?.isEmpty() ?? true; } get loaded() { diff --git a/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts b/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts index 9bf5ae90d6..d3945c807b 100644 --- a/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts +++ b/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts @@ -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'; beforeEach(async () => { @@ -16,3 +18,136 @@ test('basic assert', () => { 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); + }); +}); diff --git a/blocksuite/presets/src/fragments/doc-title/doc-title.ts b/blocksuite/presets/src/fragments/doc-title/doc-title.ts index dff9720442..aa3c5fe620 100644 --- a/blocksuite/presets/src/fragments/doc-title/doc-title.ts +++ b/blocksuite/presets/src/fragments/doc-title/doc-title.ts @@ -61,9 +61,8 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { private readonly _onTitleKeyDown = (event: KeyboardEvent) => { 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.stopPropagation(); @@ -73,10 +72,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { const rightText = this._rootModel.title.split(inlineRange.index); this._pageRoot.prependParagraphWithText(rightText); } - } else if (event.key === 'ArrowDown' && hasContent) { + } else if (event.key === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); - this._pageRoot.focusFirstParagraph(); + this._pageRoot?.focusFirstParagraph(); } else if (event.key === 'Tab') { event.preventDefault(); event.stopPropagation(); @@ -94,9 +93,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { } private get _pageRoot() { - const pageRoot = this._viewport.querySelector('affine-page-root'); - assertExists(pageRoot); - return pageRoot; + return this._viewport.querySelector('affine-page-root'); } private get _rootModel() { diff --git a/blocksuite/presets/src/fragments/outline/card/outline-card.ts b/blocksuite/presets/src/fragments/outline/card/outline-card.ts index 234f3a7e69..0510ccd90c 100644 --- a/blocksuite/presets/src/fragments/outline/card/outline-card.ts +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.ts @@ -9,7 +9,7 @@ import { import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import type { BlockModel, Blocks } from '@blocksuite/store'; 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 { 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._displayModeButtonGroup, this._displayModePanel, @@ -303,8 +303,6 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) { } override render() { - if (this.note.isEmpty.peek()) return nothing; - const { children, displayMode } = this.note; const currentMode = this._getCurrentModeLabel(displayMode); const cardHeaderClasses = classMap({