import { CodeBlockModel, ListBlockModel, NoteBlockModel, NoteDisplayMode, ParagraphBlockModel, type RootBlockModel, } from '@blocksuite/affine-model'; import { matchModels } 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 => matchModels(child, [NoteBlockModel]) && 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 => matchModels(block, [ ParagraphBlockModel, ListBlockModel, CodeBlockModel, ]) ); 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('.affine-page-viewport') ?? this.closest('.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`
this._viewport} .readonly=${this.doc.readonly} .enableFormat=${false} .wrapText=${this.wrapText} >
`; } @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 = true; }