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
+3 -2
View File
@@ -8,6 +8,7 @@ import {
} from './editors/index.js';
import { CommentInput } from './fragments/comment/comment-input.js';
import { BacklinkButton } from './fragments/doc-meta-tags/backlink-popover.js';
import { effects as docTitleEffects } from './fragments/doc-title/index.js';
import {
AFFINE_FRAME_PANEL_BODY,
FramePanelBody,
@@ -38,7 +39,6 @@ import {
AFFINE_OUTLINE_PANEL,
AFFINE_OUTLINE_VIEWER,
CommentPanel,
DocTitle,
FramePanel,
MobileOutlineMenu,
OutlinePanel,
@@ -70,9 +70,10 @@ import {
} from './fragments/outline/header/outline-setting-menu.js';
export function effects() {
docTitleEffects();
customElements.define('page-editor', PageEditor);
customElements.define('comment-input', CommentInput);
customElements.define('doc-title', DocTitle);
customElements.define(
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
OutlineNotePreviewSettingMenu
@@ -1,194 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import { ShadowlessElement } from '@blocksuite/block-std';
import type { RichText, RootBlockModel } from '@blocksuite/blocks';
import { assertExists, 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';
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 readonly _onTitleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.doc.readonly) return;
if (event.key === 'Enter' && this._pageRoot) {
event.preventDefault();
event.stopPropagation();
const inlineEditor = this._inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (inlineRange) {
const rightText = this._rootModel.title.split(inlineRange.index);
this._pageRoot.prependParagraphWithText(rightText);
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
this._pageRoot?.focusFirstParagraph();
} 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 _inlineEditor() {
return this._richTextElement.inlineEditor;
}
private get _pageRoot() {
return this._viewport.querySelector('affine-page-root');
}
private get _rootModel() {
return this.doc.root as RootBlockModel;
}
private get _viewport() {
const el = this.closest<HTMLElement>('.affine-page-viewport');
assertExists(el);
return el;
}
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}
></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;
}
export function getDocTitleByEditorHost(
editorHost: EditorHost
): DocTitle | null {
const docViewport = editorHost.closest('.affine-page-viewport');
if (!docViewport) return null;
return docViewport.querySelector('doc-title');
}
declare global {
interface HTMLElementTagNameMap {
'doc-title': DocTitle;
}
}
@@ -0,0 +1 @@
export * from '@blocksuite/affine-components/doc-title';
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './comment/index.js';
export * from './doc-title/doc-title.js';
export * from './doc-title/index.js';
export * from './frame-panel/index.js';
export * from './outline/index.js';
@@ -3,7 +3,7 @@ import { NoteDisplayMode } from '@blocksuite/blocks';
import { clamp, DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '../../../editors/editor-container.js';
import { getDocTitleByEditorHost } from '../../doc-title/doc-title.js';
import { getDocTitleByEditorHost } from '../../doc-title/index.js';
import { getHeadingBlocksFromDoc } from './query.js';
export function scrollToBlock(editor: AffineEditorContainer, blockId: string) {