feat(editor): show doc title in page block (#9975)

Close [BS-2392](https://linear.app/affine-design/issue/BS-2392/page-block-需要显示文章title)

### What Changes
- Add `<doc-title>` to edgeless page block (a.k.a the first page visible note block)
- Refactors:
  - Move `<doc-title>` to `@blocksuite/affine-component`, but you can aslo import it from `@blocksuite/preset`
  - Extract `<edgeless-note-mask>` and `<edgeless-note-background>` from `<affine-edgeless-note>` to a seperate file
  - Rewrite styles of `<affine-edgeless-note>` with `@vanilla-extract/css`

https://github.com/user-attachments/assets/a0c03239-803e-4bfa-b30e-33b919213b12
This commit is contained in:
L-Sun
2025-02-06 21:18:27 +00:00
parent 41107eafae
commit 891d9df0b1
33 changed files with 626 additions and 337 deletions

View File

@@ -0,0 +1,223 @@
import {
type NoteBlockModel,
NoteDisplayMode,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { Store } from '@blocksuite/store';
import { effect } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { focusTextModel, type RichText } from '../rich-text';
const DOC_BLOCK_CHILD_PADDING = 24;
export class DocTitle extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-title-container {
box-sizing: border-box;
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-size: 40px;
line-height: 50px;
font-weight: 700;
outline: none;
resize: none;
border: 0;
width: 100%;
max-width: var(--affine-editor-width);
margin-left: auto;
margin-right: auto;
padding: 38px 0;
padding-left: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
padding-right: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.doc-title-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
}
}
.doc-title-container-empty::before {
content: 'Title';
color: var(--affine-placeholder-color);
position: absolute;
opacity: 0.5;
pointer-events: none;
}
.doc-title-container:disabled {
background-color: transparent;
}
`;
private _getOrCreateFirstPageVisibleNote() {
const note = this._rootModel.children.find(
(child): child is NoteBlockModel =>
matchFlavours(child, ['affine:note']) &&
child.displayMode !== NoteDisplayMode.EdgelessOnly
);
if (note) return note;
const noteId = this.doc.addBlock('affine:note', {}, this._rootModel, 0);
return this.doc.getBlock(noteId)?.model as NoteBlockModel;
}
private readonly _onTitleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.doc.readonly) return;
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
const inlineRange = this.inlineEditor?.getInlineRange();
if (inlineRange) {
const rightText = this._rootModel.title.split(inlineRange.index);
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{ text: rightText },
this._getOrCreateFirstPageVisibleNote(),
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
const note = this._getOrCreateFirstPageVisibleNote();
const firstText = note?.children.find(block =>
matchFlavours(block, ['affine:paragraph', 'affine:list', 'affine:code'])
);
if (firstText) {
if (this._std) focusTextModel(this._std, firstText.id);
} else {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
note,
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
}
};
private readonly _updateTitleInMeta = () => {
this.doc.workspace.meta.setDocMeta(this.doc.id, {
title: this._rootModel.title.toString(),
});
};
private get _std() {
return this._viewport?.querySelector('editor-host')?.std;
}
private get _rootModel() {
return this.doc.root as RootBlockModel;
}
private get _viewport() {
return (
this.closest<HTMLElement>('.affine-page-viewport') ??
this.closest<HTMLElement>('.affine-edgeless-viewport')
);
}
get inlineEditor() {
return this._richTextElement.inlineEditor;
}
override connectedCallback() {
super.connectedCallback();
this._isReadonly = this.doc.readonly;
this._disposables.add(
effect(() => {
if (this._isReadonly !== this.doc.readonly) {
this._isReadonly = this.doc.readonly;
this.requestUpdate();
}
})
);
this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown);
// Workaround for inline editor skips composition event
this._disposables.addFromEvent(
this,
'compositionstart',
() => (this._isComposing = true)
);
this._disposables.addFromEvent(
this,
'compositionend',
() => (this._isComposing = false)
);
const updateMetaTitle = () => {
this._updateTitleInMeta();
this.requestUpdate();
};
this._rootModel.title.yText.observe(updateMetaTitle);
this._disposables.add(() => {
this._rootModel.title.yText.unobserve(updateMetaTitle);
});
}
override render() {
const isEmpty = !this._rootModel.title.length && !this._isComposing;
return html`
<div
class="doc-title-container ${isEmpty
? 'doc-title-container-empty'
: ''}"
data-block-is-title="true"
>
<rich-text
.yText=${this._rootModel.title.yText}
.undoManager=${this.doc.history}
.verticalScrollContainerGetter=${() => this._viewport}
.readonly=${this.doc.readonly}
.enableFormat=${false}
.wrapText=${this.wrapText}
></rich-text>
</div>
`;
}
@state()
private accessor _isComposing = false;
@state()
private accessor _isReadonly = false;
@query('rich-text')
private accessor _richTextElement!: RichText;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor wrapText = false;
}

View File

@@ -0,0 +1,11 @@
import { DocTitle } from './doc-title';
export function effects() {
customElements.define('doc-title', DocTitle);
}
declare global {
interface HTMLElementTagNameMap {
'doc-title': DocTitle;
}
}

View File

@@ -0,0 +1,3 @@
export { DocTitle } from './doc-title';
export { effects } from './effects';
export { getDocTitleByEditorHost } from './utils';

View File

@@ -0,0 +1,11 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { DocTitle } from './doc-title';
export function getDocTitleByEditorHost(
editorHost: EditorHost
): DocTitle | null {
const docViewport = editorHost.closest('.affine-page-viewport');
if (!docViewport) return null;
return docViewport.querySelector('doc-title');
}