diff --git a/blocksuite/affine/block-note/package.json b/blocksuite/affine/block-note/package.json index fd0b90bdb2..6d0eaf75ce 100644 --- a/blocksuite/affine/block-note/package.json +++ b/blocksuite/affine/block-note/package.json @@ -28,6 +28,7 @@ "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.7", "@types/mdast": "^4.0.4", + "@vanilla-extract/css": "^1.17.0", "lit": "^3.2.0", "minimatch": "^10.0.1", "zod": "^3.23.8" diff --git a/blocksuite/affine/block-note/src/components/edgeless-note-background.css.ts b/blocksuite/affine/block-note/src/components/edgeless-note-background.css.ts new file mode 100644 index 0000000000..d2a4937804 --- /dev/null +++ b/blocksuite/affine/block-note/src/components/edgeless-note-background.css.ts @@ -0,0 +1,27 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +import { + ACTIVE_NOTE_EXTRA_PADDING, + edgelessNoteContainer, +} from '../note-edgeless-block.css'; + +export const background = style({ + position: 'absolute', + borderColor: cssVar('black10'), + left: 0, + top: 0, + width: '100%', + height: '100%', + + selectors: { + [`${edgelessNoteContainer}[data-editing="true"] &`]: { + left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`, + top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`, + width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`, + height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`, + transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s', + boxShadow: cssVar('activeShadow'), + }, + }, +}); diff --git a/blocksuite/affine/block-note/src/components/edgeless-note-background.ts b/blocksuite/affine/block-note/src/components/edgeless-note-background.ts new file mode 100644 index 0000000000..e535799131 --- /dev/null +++ b/blocksuite/affine/block-note/src/components/edgeless-note-background.ts @@ -0,0 +1,195 @@ +import { + DefaultTheme, + NoteBlockModel, + NoteDisplayMode, + StrokeStyle, +} from '@blocksuite/affine-model'; +import { + FeatureFlagService, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + getClosestBlockComponentByPoint, + handleNativeRangeAtPoint, + matchFlavours, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { + type BlockComponent, + type BlockStdScope, + PropTypes, + requiredProperties, + ShadowlessElement, + stdContext, + TextSelection, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { + clamp, + Point, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { computed } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import * as styles from './edgeless-note-background.css'; + +@requiredProperties({ + note: PropTypes.instanceOf(NoteBlockModel), +}) +export class EdgelessNoteBackground extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + readonly backgroundStyle$ = computed(() => { + const themeProvider = this.std.get(ThemeProvider); + const theme = themeProvider.theme$.value; + const backgroundColor = themeProvider.generateColorProperty( + this.note.background$.value, + DefaultTheme.noteBackgrounColor, + theme + ); + + const { borderRadius, borderSize, borderStyle, shadowType } = + this.note.edgeless$.value.style; + + return { + borderRadius: borderRadius + 'px', + backgroundColor: backgroundColor, + borderWidth: `${borderSize}px`, + borderStyle: borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle, + boxShadow: !shadowType ? 'none' : `var(${shadowType})`, + }; + }); + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get doc() { + return this.std.host.doc; + } + + private get _isPageBlock() { + return ( + this.std.get(FeatureFlagService).getFlag('enable_page_block') && + // is the first page visible note + this.note.parent?.children.find( + child => + matchFlavours(child, ['affine:note']) && + child.displayMode !== NoteDisplayMode.EdgelessOnly + ) === this.note + ); + } + + private _tryAddParagraph(x: number, y: number) { + const nearest = getClosestBlockComponentByPoint( + new Point(x, y) + ) as BlockComponent | null; + if (!nearest) return; + + const nearestBBox = nearest.getBoundingClientRect(); + const yRel = y - nearestBBox.top; + + const insertPos: 'before' | 'after' = + yRel < nearestBBox.height / 2 ? 'before' : 'after'; + + const nearestModel = nearest.model as BlockModel; + const nearestModelIdx = this.note.children.indexOf(nearestModel); + + const children = this.note.children; + const siblingModel = + children[ + clamp( + nearestModelIdx + (insertPos === 'before' ? -1 : 1), + 0, + children.length + ) + ]; + + if ( + (!nearestModel.text || + !matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) && + (!siblingModel || + !siblingModel.text || + !matchFlavours(siblingModel, ['affine:paragraph', 'affine:list'])) + ) { + const [pId] = this.doc.addSiblingBlocks( + nearestModel, + [{ flavour: 'affine:paragraph' }], + insertPos + ); + + this.updateComplete + .then(() => { + this.std.selection.setGroup('note', [ + this.std.selection.create(TextSelection, { + from: { + blockId: pId, + index: 0, + length: 0, + }, + to: null, + }), + ]); + }) + .catch(console.error); + } + } + + private _handleClickAtBackground(e: MouseEvent) { + e.stopPropagation(); + if (!this.editing) return; + + const { zoom } = this.gfx.viewport; + + const rect = this.getBoundingClientRect(); + const offsetY = 16 * zoom; + const offsetX = 2 * zoom; + const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX); + const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY); + handleNativeRangeAtPoint(x, y); + + if (this.std.host.doc.readonly) return; + + this._tryAddParagraph(x, y); + } + + private _renderHeader() { + const header = this.std + .getConfig('affine:note') + ?.edgelessNoteHeader({ note: this.note, std: this.std }); + + return header; + } + + override render() { + return html`
+ ${this._isPageBlock ? this._renderHeader() : nothing} +
`; + } + + @consume({ context: stdContext }) + accessor std!: BlockStdScope; + + @property({ attribute: false }) + accessor editing: boolean = false; + + @property({ attribute: false }) + accessor note!: NoteBlockModel; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-note-background': EdgelessNoteBackground; + } +} diff --git a/blocksuite/affine/block-note/src/components/edgeless-note-mask.ts b/blocksuite/affine/block-note/src/components/edgeless-note-mask.ts new file mode 100644 index 0000000000..e8f061a895 --- /dev/null +++ b/blocksuite/affine/block-note/src/components/edgeless-note-mask.ts @@ -0,0 +1,84 @@ +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std'; +import { + almostEqual, + Bound, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { ACTIVE_NOTE_EXTRA_PADDING } from '../note-edgeless-block.css'; + +export class EdgelessNoteMask extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + protected override firstUpdated() { + const maskDOM = this.renderRoot!.querySelector('.affine-note-mask'); + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + if (!this.model.edgeless.collapse) { + const bound = Bound.deserialize(this.model.xywh); + const scale = this.model.edgeless.scale ?? 1; + const height = entry.contentRect.height * scale; + + if (!height || almostEqual(bound.h, height)) { + return; + } + + bound.h = height; + this.model.stash('xywh'); + this.model.xywh = bound.serialize(); + this.model.pop('xywh'); + } + } + }); + + observer.observe(maskDOM!); + + this._disposables.add(() => { + observer.disconnect(); + }); + } + + override render() { + const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; + return html` +
+ `; + } + + @property({ attribute: false }) + accessor editing!: boolean; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor model!: NoteBlockModel; + + @property({ attribute: false }) + accessor zoom!: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-note-mask': EdgelessNoteMask; + } +} diff --git a/blocksuite/affine/block-note/src/effects.ts b/blocksuite/affine/block-note/src/effects.ts index bd999671d5..46162bcc95 100644 --- a/blocksuite/affine/block-note/src/effects.ts +++ b/blocksuite/affine/block-note/src/effects.ts @@ -1,15 +1,18 @@ +import { EdgelessNoteBackground } from './components/edgeless-note-background'; +import { EdgelessNoteMask } from './components/edgeless-note-mask'; import type { NoteConfig } from './config'; import { NoteBlockComponent } from './note-block'; import { + AFFINE_EDGELESS_NOTE, EdgelessNoteBlockComponent, - EdgelessNoteMask, } from './note-edgeless-block'; import type { NoteBlockService } from './note-service'; export function effects() { customElements.define('affine-note', NoteBlockComponent); + customElements.define(AFFINE_EDGELESS_NOTE, EdgelessNoteBlockComponent); customElements.define('edgeless-note-mask', EdgelessNoteMask); - customElements.define('affine-edgeless-note', EdgelessNoteBlockComponent); + customElements.define('edgeless-note-background', EdgelessNoteBackground); } declare global { diff --git a/blocksuite/affine/block-note/src/index.ts b/blocksuite/affine/block-note/src/index.ts index f89b092a1a..af41585d04 100644 --- a/blocksuite/affine/block-note/src/index.ts +++ b/blocksuite/affine/block-note/src/index.ts @@ -1,5 +1,6 @@ export * from './adapters'; export * from './commands'; +export * from './components/edgeless-note-background'; export * from './config'; export * from './note-block'; export * from './note-edgeless-block'; diff --git a/blocksuite/affine/block-note/src/note-edgeless-block.css.ts b/blocksuite/affine/block-note/src/note-edgeless-block.css.ts new file mode 100644 index 0000000000..3d2e3d8a21 --- /dev/null +++ b/blocksuite/affine/block-note/src/note-edgeless-block.css.ts @@ -0,0 +1,85 @@ +import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; +import { cssVar } from '@toeverything/theme'; +import { globalStyle, style } from '@vanilla-extract/css'; + +export const ACTIVE_NOTE_EXTRA_PADDING = 20; + +export const edgelessNoteContainer = style({ + height: '100%', + padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`, + boxSizing: 'border-box', + pointerEvents: 'all', + transformOrigin: '0 0', + fontWeight: '400', + lineHeight: cssVar('lineHeight'), +}); + +export const collapseButton = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '28px', + height: '28px', + zIndex: 2, + position: 'absolute', + bottom: 0, + left: '50%', + transform: 'translateX(-50%)', + opacity: 0.2, + transition: 'opacity 0.3s', + + ':hover': { + opacity: 1, + }, + selectors: { + '&.flip': { + transform: 'translateX(-50%) rotate(180deg)', + }, + }, +}); + +export const noteBackground = style({ + position: 'absolute', + borderColor: cssVar('black10'), + left: 0, + top: 0, + width: '100%', + height: '100%', + + selectors: { + [`${edgelessNoteContainer}[data-editing="true"] &`]: { + left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`, + top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`, + width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`, + height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`, + transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s', + boxShadow: cssVar('activeShadow'), + }, + }, +}); + +globalStyle(`${edgelessNoteContainer} > doc-title`, { + position: 'relative', +}); + +globalStyle(`${edgelessNoteContainer} > doc-title .doc-title-container`, { + padding: '26px 0px', + fontSize: cssVar('fontTitle'), + fontWeight: 700, + lineHeight: '44px', +}); + +export const pageContent = style({ + width: '100%', + height: '100%', +}); + +export const collapsedContent = style({ + position: 'absolute', + background: cssVar('white'), + opacity: 0.5, + pointerEvents: 'none', + border: `2px ${cssVar('blue')} solid`, + borderTop: 'unset', + borderRadius: '0 0 8px 8px', +}); diff --git a/blocksuite/affine/block-note/src/note-edgeless-block.ts b/blocksuite/affine/block-note/src/note-edgeless-block.ts index 83e0c92c09..8a26689e25 100644 --- a/blocksuite/affine/block-note/src/note-edgeless-block.ts +++ b/blocksuite/affine/block-note/src/note-edgeless-block.ts @@ -1,160 +1,35 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; +import type { DocTitle } from '@blocksuite/affine-components/doc-title'; import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons'; -import type { NoteBlockModel } from '@blocksuite/affine-model'; -import { - DefaultTheme, - NoteDisplayMode, - StrokeStyle, -} from '@blocksuite/affine-model'; +import { NoteDisplayMode } from '@blocksuite/affine-model'; import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; +import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { - FeatureFlagService, - ThemeProvider, -} from '@blocksuite/affine-shared/services'; -import { - getClosestBlockComponentByPoint, - handleNativeRangeAtPoint, matchFlavours, stopPropagation, } from '@blocksuite/affine-shared/utils'; -import type { BlockComponent, EditorHost } from '@blocksuite/block-std'; -import { - ShadowlessElement, - TextSelection, - toGfxBlockComponent, -} from '@blocksuite/block-std'; -import { - almostEqual, - Bound, - clamp, - Point, - WithDisposable, -} from '@blocksuite/global/utils'; -import type { BlockModel } from '@blocksuite/store'; -import { computed } from '@preact/signals-core'; -import { css, html, nothing } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { toGfxBlockComponent } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { NoteBlockComponent } from './note-block.js'; +import { NoteBlockComponent } from './note-block'; +import { ACTIVE_NOTE_EXTRA_PADDING } from './note-edgeless-block.css'; +import * as styles from './note-edgeless-block.css'; -export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) { - protected override firstUpdated() { - const maskDOM = this.renderRoot!.querySelector('.affine-note-mask'); - const observer = new ResizeObserver(entries => { - for (const entry of entries) { - if (!this.model.edgeless.collapse) { - const bound = Bound.deserialize(this.model.xywh); - const scale = this.model.edgeless.scale ?? 1; - const height = entry.contentRect.height * scale; - - if (!height || almostEqual(bound.h, height)) { - return; - } - - bound.h = height; - this.model.stash('xywh'); - this.model.xywh = bound.serialize(); - this.model.pop('xywh'); - } - } - }); - - observer.observe(maskDOM!); - - this._disposables.add(() => { - observer.disconnect(); - }); - } - - override render() { - const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; - return html` -
- `; - } - - @property({ attribute: false }) - accessor editing!: boolean; - - @property({ attribute: false }) - accessor host!: EditorHost; - - @property({ attribute: false }) - accessor model!: NoteBlockModel; - - @property({ attribute: false }) - accessor zoom!: number; -} - -const ACTIVE_NOTE_EXTRA_PADDING = 20; +export const AFFINE_EDGELESS_NOTE = 'affine-edgeless-note'; export class EdgelessNoteBlockComponent extends toGfxBlockComponent( NoteBlockComponent ) { - static override styles = css` - .edgeless-note-collapse-button { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - z-index: 2; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - opacity: 0.2; - transition: opacity 0.3s; - } - .edgeless-note-collapse-button:hover { - opacity: 1; - } - .edgeless-note-collapse-button.flip { - transform: translateX(-50%) rotate(180deg); - } - - .edgeless-note-container:has(.affine-embed-synced-doc-container.editing) - > .note-background { - left: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important; - top: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important; - width: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important; - height: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important; - } - - .edgeless-note-container:has(.affine-embed-synced-doc-container.editing) - > edgeless-note-mask { - display: none; - } - `; - - private readonly _backgroundColor$ = computed(() => { - const themeProvider = this.std.get(ThemeProvider); - const theme = themeProvider.theme$.value; - return themeProvider.generateColorProperty( - this.model.background$.value, - DefaultTheme.noteBackgrounColor, - theme + private get _isPageBlock() { + return ( + this.std.get(FeatureFlagService).getFlag('enable_page_block') && + this._isFirstVisibleNote() ); - }); - - private get _enablePageHeader() { - return this.std.get(FeatureFlagService).getFlag('enable_page_block_header'); } private get _isShowCollapsedContent() { @@ -201,38 +76,21 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( return html`
`; } - private _handleClickAtBackground(e: MouseEvent) { - e.stopPropagation(); - if (!this._editing) return; - - const rect = this.getBoundingClientRect(); - const offsetY = 16 * this._zoom; - const offsetX = 2 * this._zoom; - const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX); - const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY); - handleNativeRangeAtPoint(x, y); - - if (this.doc.readonly) return; - - this._tryAddParagraph(x, y); + private _handleKeyDown(e: KeyboardEvent) { + if (e.key === 'ArrowUp' && this._isPageBlock) { + this._docTitle?.inlineEditor?.focusEnd(); + } } private _hovered() { @@ -261,14 +119,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( } } - private _renderHeader() { - const header = this.host.std - .getConfig('affine:note') - ?.edgelessNoteHeader({ note: this.model, std: this.std }); - - return header; - } - private _setCollapse(event: MouseEvent) { event.stopImmediatePropagation(); @@ -291,61 +141,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( this.selection.clear(); } - private _tryAddParagraph(x: number, y: number) { - const nearest = getClosestBlockComponentByPoint( - new Point(x, y) - ) as BlockComponent | null; - if (!nearest) return; - - const nearestBBox = nearest.getBoundingClientRect(); - const yRel = y - nearestBBox.top; - - const insertPos: 'before' | 'after' = - yRel < nearestBBox.height / 2 ? 'before' : 'after'; - - const nearestModel = nearest.model as BlockModel; - const nearestModelIdx = this.model.children.indexOf(nearestModel); - - const children = this.model.children; - const siblingModel = - children[ - clamp( - nearestModelIdx + (insertPos === 'before' ? -1 : 1), - 0, - children.length - ) - ]; - - if ( - (!nearestModel.text || - !matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) && - (!siblingModel || - !siblingModel.text || - !matchFlavours(siblingModel, ['affine:paragraph', 'affine:list'])) - ) { - const [pId] = this.doc.addSiblingBlocks( - nearestModel, - [{ flavour: 'affine:paragraph' }], - insertPos - ); - - this.updateComplete - .then(() => { - this.std.selection.setGroup('note', [ - this.std.selection.create(TextSelection, { - from: { - blockId: pId, - index: 0, - length: 0, - }, - to: null, - }), - ]); - }) - .catch(console.error); - } - } - override connectedCallback(): void { super.connectedCallback(); @@ -361,6 +156,8 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( } }) ); + + this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown); } get edgelessSlots() { @@ -423,49 +220,19 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( return nothing; const { xywh, edgeless } = model; - const { borderRadius, borderSize, borderStyle, shadowType } = - edgeless.style; - const { collapse, collapsedHeight, scale = 1 } = edgeless; + const { borderRadius } = edgeless.style; + const { collapse = false, collapsedHeight, scale = 1 } = edgeless; const bound = Bound.deserialize(xywh); - const width = bound.w / scale; const height = bound.h / scale; const style = { - height: '100%', - padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`, - boxSizing: 'border-box', borderRadius: borderRadius + 'px', - pointerEvents: 'all', - transformOrigin: '0 0', transform: `scale(${scale})`, - fontWeight: '400', - lineHeight: 'var(--affine-line-height)', }; const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; - const backgroundStyle = { - position: 'absolute', - left: `${-extra}px`, - top: `${-extra}px`, - width: `${width + extra * 2}px`, - height: `calc(100% + ${extra * 2}px)`, - borderRadius: borderRadius + 'px', - transition: this._editing - ? 'left 0.3s, top 0.3s, width 0.3s, height 0.3s' - : 'none', - backgroundColor: this._backgroundColor$.value, - border: `${borderSize}px ${ - borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle - } var(--affine-black-10)`, - boxShadow: this._editing - ? 'var(--affine-active-shadow)' - : !shadowType - ? 'none' - : `var(${shadowType})`, - }; - const isCollapsable = collapse != null && collapsedHeight != null && @@ -477,21 +244,27 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( return html`
-
- ${this._enablePageHeader ? this._renderHeader() : nothing} -
+ + + ${this._isPageBlock && !collapse + ? html`` + : nothing}
- ${isCollapsable && - (!this._isFirstVisibleNote() || !this._enablePageHeader) + ${isCollapsable && !this._isPageBlock ? html`
+ 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' && this._pageRoot) { + if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); - const inlineEditor = this._inlineEditor; - const inlineRange = inlineEditor?.getInlineRange(); + const inlineRange = this.inlineEditor?.getInlineRange(); if (inlineRange) { const rightText = this._rootModel.title.split(inlineRange.index); - this._pageRoot.prependParagraphWithText(rightText); + 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(); - this._pageRoot?.focusFirstParagraph(); + + 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(); @@ -89,12 +127,8 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { }); }; - private get _inlineEditor() { - return this._richTextElement.inlineEditor; - } - - private get _pageRoot() { - return this._viewport.querySelector('affine-page-root'); + private get _std() { + return this._viewport?.querySelector('editor-host')?.std; } private get _rootModel() { @@ -102,9 +136,14 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { } private get _viewport() { - const el = this.closest('.affine-page-viewport'); - assertExists(el); - return el; + return ( + this.closest('.affine-page-viewport') ?? + this.closest('.affine-edgeless-viewport') + ); + } + + get inlineEditor() { + return this._richTextElement.inlineEditor; } override connectedCallback() { @@ -161,6 +200,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { .verticalScrollContainerGetter=${() => this._viewport} .readonly=${this.doc.readonly} .enableFormat=${false} + .wrapText=${this.wrapText} >
`; @@ -177,18 +217,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { @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; - } + @property({ attribute: false }) + accessor wrapText = false; } diff --git a/blocksuite/affine/components/src/doc-title/effects.ts b/blocksuite/affine/components/src/doc-title/effects.ts new file mode 100644 index 0000000000..2a42c219ad --- /dev/null +++ b/blocksuite/affine/components/src/doc-title/effects.ts @@ -0,0 +1,11 @@ +import { DocTitle } from './doc-title'; + +export function effects() { + customElements.define('doc-title', DocTitle); +} + +declare global { + interface HTMLElementTagNameMap { + 'doc-title': DocTitle; + } +} diff --git a/blocksuite/affine/components/src/doc-title/index.ts b/blocksuite/affine/components/src/doc-title/index.ts new file mode 100644 index 0000000000..f5565c645f --- /dev/null +++ b/blocksuite/affine/components/src/doc-title/index.ts @@ -0,0 +1,3 @@ +export { DocTitle } from './doc-title'; +export { effects } from './effects'; +export { getDocTitleByEditorHost } from './utils'; diff --git a/blocksuite/affine/components/src/doc-title/utils.ts b/blocksuite/affine/components/src/doc-title/utils.ts new file mode 100644 index 0000000000..d70c225941 --- /dev/null +++ b/blocksuite/affine/components/src/doc-title/utils.ts @@ -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'); +} diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index d38f82f8b8..bde494fa28 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -18,7 +18,7 @@ export interface BlockSuiteFlags { enable_shape_shadow_blur: boolean; enable_mobile_keyboard_toolbar: boolean; enable_mobile_linked_doc_menu: boolean; - enable_page_block_header: boolean; + enable_page_block: boolean; } export class FeatureFlagService extends StoreExtension { @@ -41,7 +41,7 @@ export class FeatureFlagService extends StoreExtension { enable_shape_shadow_blur: false, enable_mobile_keyboard_toolbar: false, enable_mobile_linked_doc_menu: false, - enable_page_block_header: false, + enable_page_block: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { diff --git a/blocksuite/affine/widget-drag-handle/src/utils.ts b/blocksuite/affine/widget-drag-handle/src/utils.ts index e1e5b2043d..82b7257b50 100644 --- a/blocksuite/affine/widget-drag-handle/src/utils.ts +++ b/blocksuite/affine/widget-drag-handle/src/utils.ts @@ -1,3 +1,7 @@ +import { + AFFINE_EDGELESS_NOTE, + type EdgelessNoteBlockComponent, +} from '@blocksuite/affine-block-note'; import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph'; import type { ParagraphBlockModel } from '@blocksuite/affine-model'; import { DocModeProvider } from '@blocksuite/affine-shared/services'; @@ -155,7 +159,7 @@ export const getClosestNoteBlock = ( editorHost.std.get(DocModeProvider).getEditorMode() === 'page'; return isInsidePageEditor ? findClosestBlockComponent(rootComponent, point, 'affine-note') - : getHoveringNote(point)?.closest('affine-edgeless-note'); + : getHoveringNote(point); }; export const getClosestBlockByPoint = ( @@ -261,11 +265,11 @@ export function getDuplicateBlocks(blocks: BlockModel[]) { */ function getHoveringNote(point: Point) { return ( - document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) || - null + document + .elementsFromPoint(point.x, point.y) + .find( + (e): e is EdgelessNoteBlockComponent => + e.tagName.toLowerCase() === AFFINE_EDGELESS_NOTE + ) || null ); } - -function isEdgelessChildNote({ classList }: Element) { - return classList.contains('note-background'); -} diff --git a/blocksuite/blocks/package.json b/blocksuite/blocks/package.json index 5df6b4519f..c327d19887 100644 --- a/blocksuite/blocks/package.json +++ b/blocksuite/blocks/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@types/katex": "^0.16.7", "@types/lodash.isequal": "^4.5.8", + "@vanilla-extract/vite-plugin": "^5.0.0", "vitest": "3.0.5" } } diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts index 6708abface..6963a1b89f 100644 --- a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts @@ -145,8 +145,8 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { .getFlag('enable_advanced_block_visibility'); } - private get _pageBlockHeaderEnabled() { - return this.doc.get(FeatureFlagService).getFlag('enable_page_block_header'); + private get _pageBlockEnabled() { + return this.doc.get(FeatureFlagService).getFlag('enable_page_block'); } private get doc() { @@ -155,7 +155,7 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { private get _enableAutoHeight() { return !( - this._pageBlockHeaderEnabled && + this._pageBlockEnabled && this.notes.length === 1 && this.notes[0].parent?.children.find(child => matchFlavours(child, ['affine:note']) @@ -373,7 +373,7 @@ export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { onlyOne && !isFirstNote && - this._pageBlockHeaderEnabled && + this._pageBlockEnabled && !this._advancedVisibilityEnabled ? html` { { x: box.x + 50, y: box.y + box.height + 100 } ); let noteRect = await getNoteRect(page, noteId); - await expect(page.locator('.edgeless-note-collapse-button')).toBeVisible(); + await expect(page.getByTestId('edgeless-note-collapse-button')).toBeVisible(); assertRectEqual(noteRect, { x: initRect.x, y: initRect.y, @@ -102,11 +102,11 @@ test('resize note then collapse note', async ({ page }) => { h: initRect.h + 100, }); - await page.locator('.edgeless-note-collapse-button')!.click(); + await page.getByTestId('edgeless-note-collapse-button')!.click(); let domRect = await page.locator('affine-edgeless-note').boundingBox(); expect(domRect!.height).toBeCloseTo(NOTE_MIN_HEIGHT); - await page.locator('.edgeless-note-collapse-button')!.click(); + await page.getByTestId('edgeless-note-collapse-button')!.click(); domRect = await page.locator('affine-edgeless-note').boundingBox(); expect(domRect!.height).toBeCloseTo(initRect.h + 100); @@ -120,7 +120,7 @@ test('resize note then collapse note', async ({ page }) => { ); noteRect = await getNoteRect(page, noteId); await expect( - page.locator('.edgeless-note-collapse-button') + page.getByTestId('edgeless-note-collapse-button') ).not.toBeVisible(); assertRectEqual(noteRect, { x: initRect.x, diff --git a/blocksuite/tests-legacy/edgeless/note/scale.spec.ts b/blocksuite/tests-legacy/edgeless/note/scale.spec.ts index 97c65157f9..76ec6fc3d9 100644 --- a/blocksuite/tests-legacy/edgeless/note/scale.spec.ts +++ b/blocksuite/tests-legacy/edgeless/note/scale.spec.ts @@ -48,7 +48,7 @@ async function checkNoteScale( const edgelessNote = page.locator( `affine-edgeless-note[data-block-id="${noteId}"]` ); - const noteContainer = edgelessNote.locator('.edgeless-note-container'); + const noteContainer = edgelessNote.getByTestId('edgeless-note-container'); const style = await noteContainer.getAttribute('style'); if (!style) { diff --git a/blocksuite/tests-legacy/linked-page.spec.ts b/blocksuite/tests-legacy/linked-page.spec.ts index e83b2917b6..c0a832085b 100644 --- a/blocksuite/tests-legacy/linked-page.spec.ts +++ b/blocksuite/tests-legacy/linked-page.spec.ts @@ -665,6 +665,8 @@ test('linked doc can be dragged from note to surface top level block', async ({ }) => { await enterPlaygroundRoom(page); await initEmptyEdgelessState(page); + await focusTitle(page); + await type(page, 'title0'); await focusRichText(page); await createAndConvertToEmbedLinkedDoc(page); diff --git a/blocksuite/tests-legacy/utils/asserts.ts b/blocksuite/tests-legacy/utils/asserts.ts index 856a8da7ce..4431ff7148 100644 --- a/blocksuite/tests-legacy/utils/asserts.ts +++ b/blocksuite/tests-legacy/utils/asserts.ts @@ -1,5 +1,6 @@ import './declare-test-window.js'; +import type { EdgelessNoteBackground } from '@blocksuite/affine-block-note'; import type { BlockComponent, EditorHost, @@ -965,12 +966,13 @@ export async function assertEdgelessNoteBackground( const backgroundColor = await editor .locator(`affine-edgeless-note[data-block-id="${noteId}"]`) .evaluate(ele => { - const noteWrapper = - ele?.querySelector('.note-background'); + const noteWrapper = ele?.querySelector( + 'edgeless-note-background' + ); if (!noteWrapper) { throw new Error(`Could not find note: ${noteId}`); } - return noteWrapper.style.backgroundColor; + return noteWrapper.backgroundStyle$.value.backgroundColor; }); expect(toHex(backgroundColor)).toEqual(color); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx index 2168439c88..4096971f83 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/edgeless-note-header.tsx @@ -171,7 +171,7 @@ export const EdgelessNoteHeader = ({ note }: { note: NoteBlockModel }) => { const flags = useService(FeatureFlagService).flags; const insidePeekView = useInsidePeekView(); - if (!flags.enable_page_block_header) return null; + if (!flags.enable_page_block) return null; const isFirstVisibleNote = note.parent?.children.find( diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index d8d7793ceb..914be87d43 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -240,9 +240,9 @@ export const AFFINE_FLAGS = { defaultState: isCanaryBuild, }, // TODO(@L-Sun): remove this flag when ready - enable_page_block_header: { + enable_page_block: { category: 'blocksuite', - bsFlag: 'enable_page_block_header', + bsFlag: 'enable_page_block', displayName: 'com.affine.settings.workspace.experimental-features.enable-page-block-header.name', description: diff --git a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts index fa086e8797..54e70d1e1d 100644 --- a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts +++ b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts @@ -11,12 +11,15 @@ import { } from '@affine-test/kit/utils/editor'; import { pasteByKeyboard, + pressBackspace, + pressEnter, selectAllByKeyboard, undoByKeyboard, } from '@affine-test/kit/utils/keyboard'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, + type, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { expect, type Page } from '@playwright/test'; @@ -36,7 +39,8 @@ test.beforeEach(async ({ page }) => { await container.click(); }); -test.describe('edgeless page header toolbar', () => { +// the first note block is called page block +test.describe('edgeless page block', () => { const locateHeaderToolbar = (page: Page) => page.getByTestId('edgeless-page-block-header'); @@ -77,7 +81,7 @@ test.describe('edgeless page header toolbar', () => { expect(newNoteBox2).toEqual(noteBox); }); - test('page title should be displayed when page block is collapsed and hidden when page block is not collapsed', async ({ + test('page title in toolbar should be displayed when page block is collapsed and hidden when page block is not collapsed', async ({ page, }) => { const toolbar = locateHeaderToolbar(page); @@ -143,6 +147,45 @@ test.describe('edgeless page header toolbar', () => { await expect(toolbar).toBeVisible(); await expect(infoButton).toBeHidden(); }); + + test('page title should show in note when page block is not collapsed', async ({ + page, + }) => { + const note = page.locator('affine-edgeless-note'); + const docTitle = note.locator('doc-title'); + await expect(docTitle).toBeVisible(); + await expect(docTitle).toHaveText(title); + + await note.dblclick(); + await docTitle.click(); + + // clear the title + await selectAllByKeyboard(page); + await pressBackspace(page); + await expect(docTitle).toHaveText(''); + + // type new title + await type(page, 'New Title'); + await expect(docTitle).toHaveText('New Title'); + + // cursor could move between doc title and note content + await page.keyboard.press('ArrowDown'); + await type(page, 'xx'); + + const paragraphs = note.locator('affine-paragraph v-line'); + const numParagraphs = await paragraphs.count(); + await expect(paragraphs.first()).toHaveText('xxHello'); + + await page.keyboard.press('ArrowUp'); + await type(page, 'yy'); + await expect(docTitle).toHaveText('yyNew Title'); + + await pressEnter(page); + await expect(docTitle).toHaveText('yy'); + await expect(paragraphs).toHaveCount(numParagraphs + 1); + await expect(paragraphs.nth(0)).toHaveText('New Title'); + await expect(paragraphs.nth(1)).toHaveText('xxHello'); + }); }); test.describe('edgeless note element toolbar', () => { diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index ea14803769..0a46715589 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -450,6 +450,7 @@ export const PackageList = [ workspaceDependencies: [ 'blocksuite/affine/block-note', 'blocksuite/affine/block-surface', + 'blocksuite/affine/components', 'blocksuite/affine/model', 'blocksuite/affine/shared', 'blocksuite/framework/block-std', diff --git a/yarn.lock b/yarn.lock index 6ce0761898..54d4b06abe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3557,7 +3557,7 @@ __metadata: languageName: unknown linkType: soft -"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:^, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note": +"@blocksuite/affine-block-note@workspace:*, @blocksuite/affine-block-note@workspace:blocksuite/affine/block-note": version: 0.0.0-use.local resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note" dependencies: @@ -3576,6 +3576,7 @@ __metadata: "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.7" "@types/mdast": "npm:^4.0.4" + "@vanilla-extract/css": "npm:^1.17.0" lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" @@ -3941,6 +3942,7 @@ __metadata: "@types/katex": "npm:^0.16.7" "@types/lodash.isequal": "npm:^4.5.8" "@vanilla-extract/css": "npm:^1.17.0" + "@vanilla-extract/vite-plugin": "npm:^5.0.0" date-fns: "npm:^4.0.0" dompurify: "npm:^3.1.6" fflate: "npm:^0.8.2" @@ -4090,8 +4092,9 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/presets@workspace:blocksuite/presets" dependencies: - "@blocksuite/affine-block-note": "workspace:^" + "@blocksuite/affine-block-note": "workspace:*" "@blocksuite/affine-block-surface": "workspace:*" + "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-shared": "workspace:*" "@blocksuite/block-std": "workspace:*"