Files
AFFiNE-Mirror/blocksuite/affine/blocks/root/src/page/page-root-block.ts
T
Saul-Mirone 1f45cc5dec refactor(editor): unify directories naming (#11516)
**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
2025-04-07 12:34:40 +00:00

428 lines
13 KiB
TypeScript

import { appendParagraphCommand } from '@blocksuite/affine-block-paragraph';
import {
CodeBlockModel,
ListBlockModel,
NoteBlockModel,
NoteDisplayMode,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import {
PageViewportService,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import {
focusTitle,
getClosestBlockComponentByPoint,
getDocTitleInlineEditor,
getScrollContainer,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { Point } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import { BlockComponent, BlockSelection, TextSelection } from '@blocksuite/std';
import type { BlockModel, Text } from '@blocksuite/store';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { PageRootBlockWidgetName } from '../index.js';
import { PageKeyboardManager } from '../keyboard/keyboard-manager.js';
import type { PageRootService } from './page-root-service.js';
const DOC_BLOCK_CHILD_PADDING = 24;
const DOC_BOTTOM_PADDING = 32;
function testClickOnBlankArea(
state: PointerEventState,
viewportLeft: number,
viewportWidth: number,
pageWidth: number,
paddingLeft: number,
paddingRight: number
) {
const blankLeft =
viewportLeft + (viewportWidth - pageWidth) / 2 + paddingLeft;
const blankRight =
viewportLeft + (viewportWidth - pageWidth) / 2 + pageWidth - paddingRight;
return state.raw.clientX < blankLeft || state.raw.clientX > blankRight;
}
export class PageRootBlockComponent extends BlockComponent<
RootBlockModel,
PageRootService,
PageRootBlockWidgetName
> {
static override styles = css`
editor-host:has(> affine-page-root, * > affine-page-root) {
display: block;
height: 100%;
}
affine-page-root {
display: block;
height: 100%;
cursor: default;
}
.affine-page-root-block-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
font-family: var(--affine-font-family);
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-weight: 400;
max-width: var(--affine-editor-width);
margin: 0 auto;
/* Leave a place for drag-handle */
/* Do not use prettier format this style, or it will be broken */
/* prettier-ignore */
padding-left: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px);
/* prettier-ignore */
padding-right: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px);
/* prettier-ignore */
padding-bottom: var(--affine-editor-bottom-padding, ${DOC_BOTTOM_PADDING}px);
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.affine-page-root-block-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
}
}
.affine-block-element {
display: block;
}
@media print {
.selected {
background-color: transparent !important;
}
}
`;
/**
* Focus the first paragraph in the default note block.
* If there is no paragraph, create one.
* @return { id: string, created: boolean } id of the focused paragraph and whether it is created or not
*/
focusFirstParagraph = (): { id: string; created: boolean } => {
const defaultNote = this._getDefaultNoteBlock();
const firstText = defaultNote?.children.find(block =>
matchModels(block, [ParagraphBlockModel, ListBlockModel, CodeBlockModel])
);
if (firstText) {
focusTextModel(this.std, firstText.id);
return { id: firstText.id, created: false };
} else {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
defaultNote,
0
);
focusTextModel(this.std, newFirstParagraphId);
return { id: newFirstParagraphId, created: true };
}
};
keyboardManager: PageKeyboardManager | null = null;
prependParagraphWithText = (text: Text) => {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{ text },
this._getDefaultNoteBlock(),
0
);
focusTextModel(this.std, newFirstParagraphId);
};
get rootScrollContainer() {
return getScrollContainer(this);
}
get viewportProvider() {
return this.std.get(ViewportElementProvider);
}
get viewport() {
return this.viewportProvider.viewport;
}
get viewportElement(): HTMLElement {
return this.viewportProvider.viewportElement;
}
private _createDefaultNoteBlock() {
const { doc } = this;
const noteId = doc.addBlock('affine:note', {}, doc.root?.id);
return doc.getModelById(noteId) as NoteBlockModel;
}
private _getDefaultNoteBlock() {
return (
this.doc.root?.children.find(child => child.flavour === 'affine:note') ??
this._createDefaultNoteBlock()
);
}
private _initViewportResizeEffect() {
const viewport = this.viewport;
const viewportElement = this.viewportElement;
if (!viewport || !viewportElement) {
return;
}
const viewportService = this.std.get(PageViewportService);
// when observe viewportElement resize, emit viewport update event
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
for (const { target } of entries) {
if (target === viewportElement) {
viewportService.next(viewport);
break;
}
}
}
);
resizeObserver.observe(viewportElement);
this.disposables.add(() => {
resizeObserver.unobserve(viewportElement);
resizeObserver.disconnect();
});
}
override connectedCallback() {
super.connectedCallback();
this.keyboardManager = new PageKeyboardManager(this);
this.bindHotKey({
'Mod-a': () => {
const blocks = this.model.children
.filter(model => {
if (matchModels(model, [NoteBlockModel])) {
if (model.props.displayMode === NoteDisplayMode.EdgelessOnly)
return false;
return true;
}
return false;
})
.flatMap(model => {
return model.children.map(child => {
return this.std.selection.create(BlockSelection, {
blockId: child.id,
});
});
});
this.std.selection.setGroup('note', blocks);
return true;
},
ArrowUp: () => {
const selection = this.host.selection;
const sel = selection.value.find(
sel => sel.is(TextSelection) || sel.is(BlockSelection)
);
if (!sel) return;
let model: BlockModel | null = null;
let current = this.doc.getModelById(sel.blockId);
while (current && !model) {
if (current.flavour === 'affine:note') {
model = current;
} else {
current = this.doc.getParent(current);
}
}
if (!model) return;
const prevNote = this.doc.getPrev(model);
if (!prevNote || prevNote.flavour !== 'affine:note') {
const isFirstText = sel.is(TextSelection) && sel.start.index === 0;
const isBlock = sel.is(BlockSelection);
if (isBlock || isFirstText) {
focusTitle(this.host);
}
return;
}
const notes = this.doc.getModelsByFlavour('affine:note');
const index = notes.indexOf(prevNote);
if (index !== 0) return;
const range = this.std.range.value;
requestAnimationFrame(() => {
const currentRange = this.std.range.value;
if (!range || !currentRange) return;
// If the range has not changed, it means we need to manually move the cursor to the title.
if (
range.startContainer === currentRange.startContainer &&
range.startOffset === currentRange.startOffset &&
range.endContainer === currentRange.endContainer &&
range.endOffset === currentRange.endOffset
) {
const titleInlineEditor = getDocTitleInlineEditor(this.host);
if (titleInlineEditor) {
titleInlineEditor.focusEnd();
}
}
});
},
});
this.handleEvent('pointerDown', ctx => {
const event = ctx.get('pointerState');
if (
event.raw.target !== this &&
event.raw.target !== this.viewportElement &&
event.raw.target !== this.rootElementContainer
) {
return;
}
// prevent cursor jump
event.raw.preventDefault();
});
this.handleEvent('click', ctx => {
const event = ctx.get('pointerState');
if (
event.raw.target !== this &&
event.raw.target !== this.viewportElement &&
event.raw.target !== this.rootElementContainer
) {
return;
}
const notes = this.model.children.filter(
(child): child is NoteBlockModel =>
child instanceof NoteBlockModel &&
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
);
// make sure there is a block can be focused
if (notes.length === 0 || notes[notes.length - 1].children.length === 0) {
this.std.command.exec(appendParagraphCommand);
return;
}
const { paddingLeft, paddingRight } = window.getComputedStyle(
this.rootElementContainer
);
if (!this.viewport) return;
const isClickOnBlankArea = testClickOnBlankArea(
event,
this.viewport.left,
this.viewport.clientWidth,
this.rootElementContainer.clientWidth,
parseFloat(paddingLeft),
parseFloat(paddingRight)
);
if (!isClickOnBlankArea) {
const lastBlock = notes[notes.length - 1].lastChild();
if (
!lastBlock ||
!matchModels(lastBlock, [ParagraphBlockModel]) ||
lastBlock.props.text.length !== 0
) {
this.std.command.exec(appendParagraphCommand);
}
return;
}
const hostRect = this.host.getBoundingClientRect();
const x = hostRect.width / 2 + hostRect.left;
const point = new Point(x, event.raw.clientY);
const side = event.raw.clientX < x ? 'left' : 'right';
const nearestBlock = getClosestBlockComponentByPoint(point);
event.raw.preventDefault();
if (nearestBlock) {
const text = nearestBlock.model.text;
if (text) {
this.host.selection.setGroup('note', [
this.host.selection.create(TextSelection, {
from: {
blockId: nearestBlock.model.id,
index: side === 'left' ? 0 : text.length,
length: 0,
},
to: null,
}),
]);
} else {
this.host.selection.setGroup('note', [
this.host.selection.create(BlockSelection, {
blockId: nearestBlock.model.id,
}),
]);
}
} else {
if (this.host.selection.find(BlockSelection)) {
this.host.selection.clear(['block']);
}
}
return;
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._disposables.dispose();
this.keyboardManager = null;
}
override firstUpdated() {
this._initViewportResizeEffect();
const noteModels = this.model.children.filter(model =>
matchModels(model, [NoteBlockModel])
);
noteModels.forEach(note => {
this.disposables.add(
note.propsUpdated.subscribe(({ key }) => {
if (key === 'displayMode') {
this.requestUpdate();
}
})
);
});
}
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const children = this.renderChildren(this.model, child => {
const isNote = matchModels(child, [NoteBlockModel]);
const note = child as NoteBlockModel;
const displayOnEdgeless =
!!note.props.displayMode &&
note.props.displayMode === NoteDisplayMode.EdgelessOnly;
// Should remove deprecated `hidden` property in the future
return !(isNote && displayOnEdgeless);
});
this.contentEditable = String(!this.doc.readonly$.value);
return html`
<div class="affine-page-root-block-container">${children} ${widgets}</div>
`;
}
@query('.affine-page-root-block-container')
accessor rootElementContainer!: HTMLDivElement;
}