mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 15:16:28 +08:00
refactor(editor): refactor page note empty checker (#9570)
Close [BS-2320](https://linear.app/affine-design/issue/BS-2320/内容为空的状态判断)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,10 @@ export class ParagraphBlockModel extends BlockModel<ParagraphProps> {
|
||||
override flavour!: 'affine:paragraph';
|
||||
|
||||
override text!: Text;
|
||||
|
||||
override isEmpty(): boolean {
|
||||
return this.text$.value.length === 0 && this.children.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -103,6 +103,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
return Object.keys(this._elementCtorMap);
|
||||
}
|
||||
|
||||
override isEmpty(): boolean {
|
||||
return this._elementModels.size === 0 && this.children.length === 0;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.created.once(() => this._init());
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export class Blocks {
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return Object.values(this._blocks.peek()).length === 0;
|
||||
return this.root?.isEmpty() ?? true;
|
||||
}
|
||||
|
||||
get loaded() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user