import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons'; import type { NoteBlockModel } from '@blocksuite/affine-model'; import { DefaultTheme, NoteDisplayMode, StrokeStyle, } from '@blocksuite/affine-model'; import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; import { 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, 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 { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { NoteBlockComponent } from './note-block.js'; 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 display!: boolean; @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 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-collapse-button.hide { display: none; } .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 _isShowCollapsedContent() { return this.model.edgeless.collapse && (this._isResizing || this._isHover); } get _zoom() { return this.gfx.viewport.zoom; } get rootService() { return this.std.getService('affine:page'); } private _collapsedContent() { if (!this._isShowCollapsedContent) { return nothing; } const { xywh, edgeless } = this.model; const { borderSize } = edgeless.style; const extraPadding = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; const extraBorder = this._editing ? borderSize : 0; const bound = Bound.deserialize(xywh); const scale = edgeless.scale ?? 1; const width = bound.w / scale + extraPadding * 2 + extraBorder; const height = bound.h / scale; const rect = this._notePageContent?.getBoundingClientRect(); if (!rect) return nothing; const zoom = this.gfx.viewport.zoom; this._noteFullHeight = rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING; if (height >= this._noteFullHeight) { return nothing; } 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 _hovered() { if ( this.selection.value.some( sel => sel.type === 'surface' && sel.blockId === this.model.id ) ) { this._isHover = true; } } private _leaved() { if (this._isHover) { this._isHover = false; } } private _setCollapse(event: MouseEvent) { event.stopImmediatePropagation(); const { collapse, collapsedHeight } = this.model.edgeless; if (collapse) { this.model.doc.updateBlock(this.model, () => { this.model.edgeless.collapse = false; }); } else if (collapsedHeight) { const { xywh, edgeless } = this.model; const bound = Bound.deserialize(xywh); bound.h = collapsedHeight * (edgeless.scale ?? 1); this.model.doc.updateBlock(this.model, () => { this.model.edgeless.collapse = true; this.model.xywh = bound.serialize(); }); } 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('text', { from: { blockId: pId, index: 0, length: 0, }, to: null, }), ]); }) .catch(console.error); } } override connectedCallback(): void { super.connectedCallback(); const selection = this.gfx.selection; this._editing = selection.has(this.model.id) && selection.editing; this._disposables.add( selection.slots.updated.on(() => { if (selection.has(this.model.id) && selection.editing) { this._editing = true; } else { this._editing = false; } }) ); } get edgelessSlots() { return this.std.get(EdgelessLegacySlotIdentifier); } override firstUpdated() { const { _disposables } = this; const selection = this.gfx.selection; _disposables.add( this.edgelessSlots.elementResizeStart.on(() => { if (selection.selectedElements.includes(this.model)) { this._isResizing = true; } }) ); _disposables.add( this.edgelessSlots.elementResizeEnd.on(() => { this._isResizing = false; }) ); const observer = new MutationObserver(() => { const rect = this._notePageContent?.getBoundingClientRect(); if (!rect) return; const zoom = this.gfx.viewport.zoom; const scale = this.model.edgeless.scale ?? 1; this._noteFullHeight = rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING; }); if (this._notePageContent) { observer.observe(this, { childList: true, subtree: true }); _disposables.add(() => observer.disconnect()); } } override getRenderingRect() { const { xywh, edgeless } = this.model; const { collapse, scale = 1 } = edgeless; const bound = Bound.deserialize(xywh); const width = bound.w / scale; const height = bound.h / scale; return { x: bound.x, y: bound.y, w: width, h: collapse ? height : 'inherit', zIndex: this.toZIndex(), }; } override renderGfxBlock() { const { model } = this; const { displayMode } = model; if (!!displayMode && displayMode === NoteDisplayMode.DocOnly) return nothing; const { xywh, edgeless } = model; const { borderRadius, borderSize, borderStyle, shadowType } = edgeless.style; const { collapse, 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 && collapsedHeight !== this._noteFullHeight; const isCollapseArrowUp = collapse ? this._noteFullHeight < height : !!collapsedHeight && collapsedHeight < height; return html`