import { changeNoteDisplayMode } from '@blocksuite/affine-block-note'; import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface'; import type { EdgelessColorPickerButton, PickColorEvent, } from '@blocksuite/affine-components/color-picker'; import { packColor, packColorsWithColorScheme, } from '@blocksuite/affine-components/color-picker'; import { ExpandIcon, LineStyleIcon, NoteCornerIcon, NoteShadowIcon, ScissorsIcon, ShrinkIcon, SmallArrowDownIcon, } from '@blocksuite/affine-components/icons'; import { type EditorMenuButton, renderToolbarSeparator, } from '@blocksuite/affine-components/toolbar'; import { type ColorScheme, DefaultTheme, NoteBlockModel, NoteDisplayMode, resolveColor, type StrokeStyle, } from '@blocksuite/affine-model'; import { FeatureFlagService, NotificationProvider, SidebarExtensionIdentifier, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { matchFlavours } from '@blocksuite/affine-shared/utils'; import { Bound, countBy, maxBy, WithDisposable, } from '@blocksuite/global/utils'; import { LinkedPageIcon } from '@blocksuite/icons/lit'; import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { property, query } from 'lit/decorators.js'; import { join } from 'lit/directives/join.js'; import { createRef, type Ref, ref } from 'lit/directives/ref.js'; import { when } from 'lit/directives/when.js'; import { type LineStyleEvent, LineStylesPanel, } from '../../edgeless/components/panel/line-styles-panel.js'; import { getTooltipWithShortcut } from '../../edgeless/components/utils.js'; import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; import * as styles from './styles.css'; const SIZE_LIST = [ { name: 'None', value: 0 }, { name: 'Small', value: 8 }, { name: 'Medium', value: 16 }, { name: 'Large', value: 24 }, { name: 'Huge', value: 32 }, ] as const; const DisplayModeMap = { [NoteDisplayMode.DocAndEdgeless]: 'Both', [NoteDisplayMode.EdgelessOnly]: 'Edgeless', [NoteDisplayMode.DocOnly]: 'Page', } as const satisfies Record; function getMostCommonBackground( elements: NoteBlockModel[], colorScheme: ColorScheme ): string { const colors = countBy(elements, (ele: NoteBlockModel) => resolveColor(ele.background, colorScheme) ); const max = maxBy(Object.entries(colors), ([_k, count]) => count); return max ? (max[0] as string) : resolveColor(DefaultTheme.noteBackgrounColor, colorScheme); } export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { get crud() { return this.edgeless.std.get(EdgelessCRUDIdentifier); } private readonly _setBackground = (background: string) => { this.notes.forEach(element => { this.crud.updateElement(element.id, { background }); }); }; private readonly _setBorderRadius = (borderRadius: number) => { this.notes.forEach(note => { const props = { edgeless: { style: { ...note.edgeless.style, borderRadius, }, }, }; this.crud.updateElement(note.id, props); }); }; private readonly _setNoteScale = (scale: number) => { this.notes.forEach(note => { this.doc.updateBlock(note, () => { const bound = Bound.deserialize(note.xywh); const oldScale = note.edgeless.scale ?? 1; const ratio = scale / oldScale; bound.w *= ratio; bound.h *= ratio; const xywh = bound.serialize(); note.xywh = xywh; note.edgeless.scale = scale; }); }); }; pickColor = (e: PickColorEvent) => { const field = 'background'; if (e.type === 'pick') { const color = e.detail.value; this.notes.forEach(element => { const props = packColor(field, color); this.crud.updateElement(element.id, props); }); return; } this.notes.forEach(ele => ele[e.type === 'start' ? 'stash' : 'pop'](field)); }; private get _advancedVisibilityEnabled() { return this.doc .get(FeatureFlagService) .getFlag('enable_advanced_block_visibility'); } private get _pageBlockEnabled() { return this.doc.get(FeatureFlagService).getFlag('enable_page_block'); } private get doc() { return this.edgeless.doc; } private get _enableAutoHeight() { return !( this._pageBlockEnabled && this.notes.length === 1 && this.notes[0].parent?.children.find(child => matchFlavours(child, [NoteBlockModel]) ) === this.notes[0] ); } private _getScaleLabel(scale: number) { return Math.round(scale * 100) + '%'; } private _handleNoteSlicerButtonClick() { const surfaceService = this.edgeless.service; if (!surfaceService) return; this.edgeless.slots.toggleNoteSlicer.emit(); } private _setCollapse() { this.notes.forEach(note => { const { collapse, collapsedHeight } = note.edgeless; if (collapse) { this.doc.updateBlock(note, () => { note.edgeless.collapse = false; }); } else if (collapsedHeight) { const { xywh, edgeless } = note; const bound = Bound.deserialize(xywh); bound.h = collapsedHeight * (edgeless.scale ?? 1); this.doc.updateBlock(note, () => { note.edgeless.collapse = true; note.xywh = bound.serialize(); }); } }); this.requestUpdate(); } private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) { this.edgeless.std.command.exec(changeNoteDisplayMode, { noteId: note.id, mode: newMode, stopCapture: true, }); // if change note to page only, should clear the selection if (newMode === NoteDisplayMode.DocOnly) { this.edgeless.service.selection.clear(); } const abortController = new AbortController(); const clear = () => { this.doc.history.off('stack-item-added', addHandler); this.doc.history.off('stack-item-popped', popHandler); disposable.dispose(); }; const closeNotify = () => { abortController.abort(); clear(); }; const addHandler = this.doc.history.on('stack-item-added', closeNotify); const popHandler = this.doc.history.on('stack-item-popped', closeNotify); const disposable = this.edgeless.std.host.slots.unmounted.on(closeNotify); const undo = () => { this.doc.undo(); closeNotify(); }; const viewInToc = () => { const sidebar = this.edgeless.std.getOptional(SidebarExtensionIdentifier); sidebar?.open('outline'); closeNotify(); }; const title = newMode !== NoteDisplayMode.EdgelessOnly ? 'Note displayed in Page Mode' : 'Note removed from Page Mode'; const message = newMode !== NoteDisplayMode.EdgelessOnly ? 'Content added to your page.' : 'Content removed from your page.'; const notification = this.edgeless.std.getOptional(NotificationProvider); notification?.notify({ title: title, message: `${message}. Find it in the TOC for quick navigation.`, accent: 'success', duration: 5 * 1000, footer: html`
`, abort: abortController.signal, onClose: () => { clear(); }, }); } private _setShadowType(shadowType: string) { this.notes.forEach(note => { const props = { edgeless: { style: { ...note.edgeless.style, shadowType, }, }, }; this.crud.updateElement(note.id, props); }); } private _setStrokeStyle(borderStyle: StrokeStyle) { this.notes.forEach(note => { const props = { edgeless: { style: { ...note.edgeless.style, borderStyle, }, }, }; this.crud.updateElement(note.id, props); }); } private _setStrokeWidth(borderSize: number) { this.notes.forEach(note => { const props = { edgeless: { style: { ...note.edgeless.style, borderSize, }, }, }; this.crud.updateElement(note.id, props); }); } private _setStyles({ type, value }: LineStyleEvent) { if (type === 'size') { this._setStrokeWidth(value); return; } if (type === 'lineStyle') { this._setStrokeStyle(value); } } override render() { const len = this.notes.length; const note = this.notes[0]; const { edgeless, displayMode } = note; const { shadowType, borderRadius, borderSize, borderStyle } = edgeless.style; const colorScheme = this.edgeless.surface.renderer.getColorScheme(); const background = getMostCommonBackground(this.notes, colorScheme); const { collapse } = edgeless; const scale = edgeless.scale ?? 1; const currentMode = DisplayModeMap[displayMode]; const onlyOne = len === 1; const isDocOnly = displayMode === NoteDisplayMode.DocOnly; const isFirstNote = onlyOne && note.parent?.children.find(child => matchFlavours(child, [NoteBlockModel]) ) === note; const theme = this.edgeless.std.get(ThemeProvider).theme; const buttons = [ onlyOne && this._advancedVisibilityEnabled ? html` Show in ${currentMode} ${SmallArrowDownIcon} `} > this._setDisplayMode(note, newMode)} > ` : nothing, onlyOne && !isFirstNote && this._pageBlockEnabled && !this._advancedVisibilityEnabled ? html` this._setDisplayMode( note, displayMode === NoteDisplayMode.EdgelessOnly ? NoteDisplayMode.DocAndEdgeless : NoteDisplayMode.EdgelessOnly )} > ${LinkedPageIcon({ width: '20px', height: '20px' })} ${displayMode === NoteDisplayMode.EdgelessOnly ? 'Display In Page' : 'Displayed In Page'} ` : nothing, isDocOnly ? nothing : when( this.edgeless.doc .get(FeatureFlagService) .getFlag('enable_color_picker'), () => { const { type, colors } = packColorsWithColorScheme( colorScheme, background, note.background ); return html` `; }, () => html` `} > ` ), isDocOnly ? nothing : html` ${NoteShadowIcon}${SmallArrowDownIcon} `} > this._setShadowType(value)} > ${LineStyleIcon}${SmallArrowDownIcon} `} >
${LineStylesPanel({ selectedLineSize: borderSize, selectedLineStyle: borderStyle, onClick: event => this._setStyles(event), })}
${NoteCornerIcon}${SmallArrowDownIcon} `} > this._setBorderRadius(size)} .onPopperCose=${() => this._cornersPanelRef.value?.hide()} > `, onlyOne && this._advancedVisibilityEnabled ? html` this._handleNoteSlicerButtonClick()} > ${ScissorsIcon} ` : nothing, onlyOne ? this.quickConnectButton : nothing, this._enableAutoHeight ? html` this._setCollapse()} > ${collapse ? ExpandIcon : ShrinkIcon} ` : nothing, html` ${this._getScaleLabel(scale)}${SmallArrowDownIcon} `} > this._setNoteScale(scale)} .onPopperCose=${() => this._scalePanelRef.value?.hide()} > `, ]; return join( buttons.filter(button => button !== nothing), renderToolbarSeparator ); } private accessor _cornersPanelRef: Ref = createRef(); private accessor _scalePanelRef: Ref = createRef(); @query('edgeless-color-picker-button.background') accessor backgroundButton!: EdgelessColorPickerButton; @property({ attribute: false }) accessor edgeless!: EdgelessRootBlockComponent; @property({ attribute: false }) accessor enableNoteSlicer!: boolean; @property({ attribute: false }) accessor notes: NoteBlockModel[] = []; @property({ attribute: false }) accessor quickConnectButton!: TemplateResult<1> | typeof nothing; } export function renderNoteButton( edgeless: EdgelessRootBlockComponent, notes?: NoteBlockModel[], quickConnectButton?: TemplateResult<1>[] ) { if (!notes?.length) return nothing; return html` `; }