chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,95 @@
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import { html, type TemplateResult } from 'lit';
import type { AffineFormatBarWidget } from '../format-bar.js';
import { HighlightButton } from './highlight/highlight-button.js';
import { ParagraphButton } from './paragraph-button.js';
export function ConfigRenderer(formatBar: AffineFormatBarWidget) {
return (
formatBar.configItems
.filter(item => {
if (item.type === 'paragraph-action') {
return false;
}
if (item.type === 'highlighter-dropdown') {
const [supported] = isFormatSupported(
formatBar.std.command.chain()
).run();
return supported;
}
if (item.type === 'inline-action') {
return item.showWhen(formatBar.std.command.chain(), formatBar);
}
return true;
})
.map(item => {
let template: TemplateResult | null = null;
switch (item.type) {
case 'divider':
template = renderToolbarSeparator();
break;
case 'highlighter-dropdown': {
template = HighlightButton(formatBar);
break;
}
case 'paragraph-dropdown':
template = ParagraphButton(formatBar);
break;
case 'inline-action': {
template = html`
<editor-icon-button
data-testid=${item.id}
?active=${item.isActive(
formatBar.std.command.chain(),
formatBar
)}
.tooltip=${item.name}
@click=${() => {
item.action(formatBar.std.command.chain(), formatBar);
formatBar.requestUpdate();
}}
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
</editor-icon-button>
`;
break;
}
case 'custom': {
template = item.render(formatBar);
break;
}
default:
template = null;
}
return [template, item] as const;
})
.filter(([template]) => template !== null && template !== undefined)
// 1. delete the redundant dividers in the middle
.filter(([_, item], index, list) => {
if (
item.type === 'divider' &&
index + 1 < list.length &&
list[index + 1][1].type === 'divider'
) {
return false;
}
return true;
})
// 2. delete the redundant dividers at the head and tail
.filter(([_, item], index, list) => {
if (item.type === 'divider') {
if (index === 0) {
return false;
}
if (index === list.length - 1) {
return false;
}
}
return true;
})
.map(([template]) => template)
);
}
@@ -0,0 +1,42 @@
interface HighlightConfig {
name: string;
color: string | null;
hotkey: string | null;
}
const colors = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
];
export const backgroundConfig: HighlightConfig[] = [
{
name: 'Default Background',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)} Background`,
color: `var(--affine-text-highlight-${color})`,
hotkey: null,
})),
];
export const foregroundConfig: HighlightConfig[] = [
{
name: 'Default Color',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)}`,
color: `var(--affine-text-highlight-foreground-${color})`,
hotkey: null,
})),
];
@@ -0,0 +1,161 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import {
ArrowDownIcon,
HighLightDuotoneIcon,
TextBackgroundDuotoneIcon,
TextForegroundDuotoneIcon,
} from '@blocksuite/affine-components/icons';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import type { AffineFormatBarWidget } from '../../format-bar.js';
import { backgroundConfig, foregroundConfig } from './consts.js';
enum HighlightType {
Foreground,
Background,
}
let lastUsedColor: string | null = null;
let lastUsedHighlightType: HighlightType = HighlightType.Background;
const updateHighlight = (
host: EditorHost,
color: string | null,
highlightType: HighlightType
) => {
lastUsedColor = color;
lastUsedHighlightType = highlightType;
const payload: {
styles: AffineTextAttributes;
} = {
styles: {
color: highlightType === HighlightType.Foreground ? color : null,
background: highlightType === HighlightType.Background ? color : null,
},
};
host.std.command
.chain()
.try(chain => [
chain.getTextSelection().formatText(payload),
chain.getBlockSelections().formatBlock(payload),
chain.formatNative(payload),
])
.run();
};
const HighlightPanel = (
formatBar: AffineFormatBarWidget,
containerRef?: RefOrCallback
) => {
return html`
<editor-menu-content class="highlight-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">
<!-- Text Color Highlight -->
<div class="highligh-panel-heading">Color</div>
${foregroundConfig.map(
({ name, color }) => html`
<editor-menu-action
data-testid="${color ?? 'unset'}"
@click="${() => {
updateHighlight(
formatBar.host,
color,
HighlightType.Foreground
);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color}">
${TextForegroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
<!-- Text Background Highlight -->
<div class="highligh-panel-heading">Background</div>
${backgroundConfig.map(
({ name, color }) => html`
<editor-menu-action
@click="${() => {
updateHighlight(
formatBar.host,
color,
HighlightType.Background
);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color ?? 'transparent'}">
${TextBackgroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
</div>
</editor-menu-content>
`;
};
export const HighlightButton = (formatBar: AffineFormatBarWidget) => {
const editorHost = formatBar.host;
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const button =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-button');
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
assertExists(button);
assertExists(panel);
panel.style.display = 'flex';
computePosition(button, panel, {
placement: 'bottom',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const highlightPanel = HighlightPanel(formatBar, setFloating);
return html`
<div class="highlight-button" ${ref(setReference)}>
<editor-icon-button
class="highlight-icon"
data-last-used="${lastUsedColor ?? 'unset'}"
@click="${() =>
updateHighlight(editorHost, lastUsedColor, lastUsedHighlightType)}"
>
<span style="display: flex; color: ${lastUsedColor}">
${HighLightDuotoneIcon}
</span>
${ArrowDownIcon}
</editor-icon-button>
${highlightPanel}
</div>
`;
};
@@ -0,0 +1,127 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import { textConversionConfigs } from '../../../../_common/configs/text-conversion.js';
import type { ParagraphActionConfigItem } from '../config.js';
import type { AffineFormatBarWidget } from '../format-bar.js';
interface ParagraphPanelProps {
host: EditorHost;
formatBar: AffineFormatBarWidget;
ref?: RefOrCallback;
}
const ParagraphPanel = ({
formatBar,
host,
ref: containerRef,
}: ParagraphPanelProps) => {
const config = formatBar.configItems
.filter(
(item): item is ParagraphActionConfigItem =>
item.type === 'paragraph-action'
)
.filter(({ flavour }) => host.doc.schema.flavourSchemaMap.has(flavour));
const renderedConfig = repeat(
config,
item => html`
<editor-menu-action
data-testid="${item.id}"
@click="${() => item.action(formatBar.std.command.chain(), formatBar)}"
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
${item.name}
</editor-menu-action>
`
);
return html`
<editor-menu-content class="paragraph-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">${renderedConfig}</div>
</editor-menu-content>
`;
};
export const ParagraphButton = (formatBar: AffineFormatBarWidget) => {
if (formatBar.displayType !== 'text' && formatBar.displayType !== 'block') {
return null;
}
const selectedBlocks = formatBar.selectedBlocks;
// only support model with text
if (selectedBlocks.some(el => !el.model.text)) {
return null;
}
const paragraphIcon =
selectedBlocks.length < 1
? textConversionConfigs[0].icon
: (textConversionConfigs.find(
({ flavour, type }) =>
selectedBlocks[0].flavour === flavour &&
(selectedBlocks[0].model as ParagraphBlockModel).type === type
)?.icon ?? textConversionConfigs[0].icon);
const rootComponent = formatBar.block;
if (rootComponent.model.flavour !== 'affine:page') {
console.error('paragraph button host is not a page component');
return null;
}
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const formatQuickBarElement = formatBar.formatBarElement;
const button =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-button');
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
assertExists(button);
assertExists(panel);
assertExists(formatQuickBarElement, 'format quick bar should exist');
panel.style.display = 'flex';
computePosition(formatQuickBarElement, panel, {
placement: 'top-start',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const paragraphPanel = ParagraphPanel({
formatBar,
host: formatBar.host,
ref: setFloating,
});
return html`
<div class="paragraph-button" ${ref(setReference)}>
<editor-icon-button class="paragraph-button-icon">
${paragraphIcon} ${ArrowDownIcon}
</editor-icon-button>
${paragraphPanel}
</div>
`;
};
@@ -0,0 +1,453 @@
import {
BoldIcon,
BulletedListIcon,
CheckBoxIcon,
CodeIcon,
CopyIcon,
DatabaseTableViewIcon20,
DeleteIcon,
DuplicateIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ItalicIcon,
LinkedDocIcon,
LinkIcon,
MoreVerticalIcon,
NumberedListIcon,
QuoteIcon,
StrikethroughIcon,
TextIcon,
UnderlineIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type {
Chain,
CommandKeyToData,
InitCommandCtx,
} from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils';
import { Slice } from '@blocksuite/store';
import { html, type TemplateResult } from 'lit';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '../../../_common/utils/render-linked-doc.js';
import { convertToDatabase } from '../../../database-block/data-source.js';
import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js';
import { FormatBarContext } from './context.js';
import type { AffineFormatBarWidget } from './format-bar.js';
export type DividerConfigItem = {
type: 'divider';
};
export type HighlighterDropdownConfigItem = {
type: 'highlighter-dropdown';
};
export type ParagraphDropdownConfigItem = {
type: 'paragraph-dropdown';
};
export type InlineActionConfigItem = {
id: string;
name: string;
type: 'inline-action';
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
isActive: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
showWhen: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
};
export type ParagraphActionConfigItem = {
id: string;
type: 'paragraph-action';
name: string;
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
flavour: string;
};
export type CustomConfigItem = {
type: 'custom';
render: (formatBar: AffineFormatBarWidget) => TemplateResult | null;
};
export type FormatBarConfigItem =
| DividerConfigItem
| HighlighterDropdownConfigItem
| ParagraphDropdownConfigItem
| ParagraphActionConfigItem
| InlineActionConfigItem
| CustomConfigItem;
export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) {
toolbar
.clearConfig()
.addParagraphDropdown()
.addDivider()
.addTextStyleToggle({
key: 'bold',
action: chain => chain.toggleBold().run(),
icon: BoldIcon,
})
.addTextStyleToggle({
key: 'italic',
action: chain => chain.toggleItalic().run(),
icon: ItalicIcon,
})
.addTextStyleToggle({
key: 'underline',
action: chain => chain.toggleUnderline().run(),
icon: UnderlineIcon,
})
.addTextStyleToggle({
key: 'strike',
action: chain => chain.toggleStrike().run(),
icon: StrikethroughIcon,
})
.addTextStyleToggle({
key: 'code',
action: chain => chain.toggleCode().run(),
icon: CodeIcon,
})
.addTextStyleToggle({
key: 'link',
action: chain => chain.toggleLink().run(),
icon: LinkIcon,
})
.addDivider()
.addHighlighterDropdown()
.addDivider()
.addInlineAction({
id: 'convert-to-database',
name: 'Create Table',
icon: DatabaseTableViewIcon20,
isActive: () => false,
action: () => {
convertToDatabase(toolbar.host, tableViewMeta.type);
},
showWhen: chain => {
const middleware = (count = 0) => {
return (
ctx: CommandKeyToData<'selectedBlocks'>,
next: () => void
) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks || selectedBlocks.length === count) return;
const allowed = selectedBlocks.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
if (!allowed) return;
next();
};
};
let [result] = chain
.getTextSelection()
.getSelectedBlocks({
types: ['text'],
})
.inline(middleware(1))
.run();
if (result) return true;
[result] = chain
.tryAll(chain => [
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedBlocks({
types: ['block', 'image'],
})
.inline(middleware(0))
.run();
return result;
},
})
.addDivider()
.addInlineAction({
id: 'convert-to-linked-doc',
name: 'Create Linked Doc',
icon: LinkedDocIcon,
isActive: () => false,
action: (chain, formatBar) => {
const [_, ctx] = chain
.getSelectedModels({
types: ['block', 'text'],
mode: 'highest',
})
.draftSelectedModels()
.run();
const { draftedModels, selectedModels } = ctx;
if (!selectedModels?.length || !draftedModels) return;
const host = formatBar.host;
host.selection.clear();
const doc = host.doc;
const autofill = getTitleFromSelectedModels(selectedModels);
void promptDocTitle(host, autofill).then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
host.std,
doc,
draftedModels,
title
);
notifyDocCreated(host, doc);
host.std.getOptional(TelemetryProvider)?.track('DocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
host.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
});
},
showWhen: chain => {
const [_, ctx] = chain
.getSelectedModels({
types: ['block', 'text'],
mode: 'highest',
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
icon: TextIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
icon: Heading1Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
icon: Heading2Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
icon: Heading3Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
icon: Heading4Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
icon: Heading5Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
icon: Heading6Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
icon: BulletedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
icon: NumberedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
icon: CheckBoxIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:code',
name: 'Code Block',
icon: CodeIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
icon: QuoteIcon,
});
}
export const BUILT_IN_GROUPS: MenuItemGroup<FormatBarContext>[] = [
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: c => c.doc.readonly,
action: c => {
c.std.command
.chain()
.getSelectedModels()
.with({
onCopy: () => {
toast(c.host, 'Copied to clipboard');
},
})
.draftSelectedModels()
.copySelectedModels()
.run();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: c => c.doc.readonly,
action: c => {
c.doc.captureSync();
c.std.command
.chain()
.try(cmd => [
cmd
.getTextSelection()
.inline<'currentSelectionPath'>((ctx, next) => {
const textSelection = ctx.currentTextSelection;
assertExists(textSelection);
const end = textSelection.to ?? textSelection.from;
next({ currentSelectionPath: end.blockId });
}),
cmd
.getBlockSelections()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
assertExists(currentBlockSelections);
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
])
.getBlockIndex()
.getSelectedModels()
.draftSelectedModels()
.inline((ctx, next) => {
if (!ctx.draftedModels) {
return next();
}
ctx.draftedModels
.then(models => {
const slice = Slice.fromModels(ctx.std.doc, models);
return ctx.std.clipboard.duplicateSlice(
slice,
ctx.std.doc,
ctx.parentBlock?.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
);
})
.catch(console.error);
return next();
})
.run();
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: c => c.doc.readonly,
action: c => {
// remove text
const [result] = c.std.command
.chain()
.getTextSelection()
.deleteText()
.run();
if (result) {
return;
}
// remove blocks
c.std.command
.chain()
.tryAll(chain => [
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedModels()
.deleteSelectedModels()
.run();
c.toolbar.reset();
},
},
],
},
];
export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
const context = new FormatBarContext(toolbar);
const actions = renderGroups(toolbar.moreGroups, context);
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button="${html`
<editor-icon-button aria-label="More" .tooltip=${'More'}>
${MoreVerticalIcon}
</editor-icon-button>
`}"
>
<div data-size="large" data-orientation="vertical">${actions}</div>
</editor-menu-button>
`;
}
@@ -0,0 +1,65 @@
import { MenuContext } from '../../configs/toolbar.js';
import type { AffineFormatBarWidget } from './format-bar.js';
export class FormatBarContext extends MenuContext {
get doc() {
return this.toolbar.host.doc;
}
get host() {
return this.toolbar.host;
}
get selectedBlockModels() {
const [success, result] = this.std.command
.chain()
.tryAll(chain => [
chain.getTextSelection(),
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedModels({
mode: 'highest',
})
.run();
if (!success) {
return [];
}
// should return an empty array if `to` of the range is null
if (
result.currentTextSelection &&
!result.currentTextSelection.to &&
result.currentTextSelection.from.length === 0
) {
return [];
}
if (result.selectedModels?.length) {
return result.selectedModels;
}
return [];
}
get std() {
return this.toolbar.std;
}
constructor(public toolbar: AffineFormatBarWidget) {
super();
}
isEmpty() {
return this.selectedBlockModels.length === 0;
}
isMultiple() {
return this.selectedBlockModels.length > 1;
}
isSingle() {
return this.selectedBlockModels.length === 1;
}
}
@@ -0,0 +1,604 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import {
cloneGroups,
type MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type {
BaseSelection,
BlockComponent,
CursorSelection,
} from '@blocksuite/block-std';
import { WidgetComponent } from '@blocksuite/block-std';
import {
assertExists,
DisposableGroup,
nextTick,
} from '@blocksuite/global/utils';
import {
autoUpdate,
computePosition,
inline,
offset,
type Placement,
type ReferenceElement,
shift,
} from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
import { ConfigRenderer } from './components/config-renderer.js';
import {
BUILT_IN_GROUPS,
type FormatBarConfigItem,
type InlineActionConfigItem,
type ParagraphActionConfigItem,
toolbarDefaultConfig,
toolbarMoreButton,
} from './config.js';
import type { FormatBarContext } from './context.js';
import { formatBarStyle } from './styles.js';
export const AFFINE_FORMAT_BAR_WIDGET = 'affine-format-bar-widget';
export class AffineFormatBarWidget extends WidgetComponent {
static override styles = formatBarStyle;
private _abortController = new AbortController();
private _floatDisposables: DisposableGroup | null = null;
private _lastCursor: CursorSelection | undefined = undefined;
private _placement: Placement = 'top';
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<FormatBarContext>[] = cloneGroups(BUILT_IN_GROUPS);
private get _selectionManager() {
return this.host.selection;
}
get displayType() {
return this._displayType;
}
get nativeRange() {
const sl = document.getSelection();
if (!sl || sl.rangeCount === 0) return null;
return sl.getRangeAt(0);
}
get selectedBlocks() {
return this._selectedBlocks;
}
private _calculatePlacement() {
const rootComponent = this.block;
this.handleEvent('dragStart', () => {
this._dragging = true;
});
this.handleEvent('dragEnd', () => {
this._dragging = false;
});
// calculate placement
this.disposables.add(
this.host.event.add('pointerUp', ctx => {
let targetRect: DOMRect | null = null;
if (this.displayType === 'text' || this.displayType === 'native') {
const range = this.nativeRange;
if (!range) {
this.reset();
return;
}
targetRect = range.getBoundingClientRect();
} else if (this.displayType === 'block') {
const block = this._selectedBlocks[0];
if (!block) return;
targetRect = block.getBoundingClientRect();
} else {
return;
}
const { top: editorHostTop, bottom: editorHostBottom } =
this.host.getBoundingClientRect();
const e = ctx.get('pointerState');
if (editorHostBottom - targetRect.bottom < 50) {
this._placement = 'top';
} else if (targetRect.top - Math.max(editorHostTop, 0) < 50) {
this._placement = 'bottom';
} else if (e.raw.y < targetRect.top + targetRect.height / 2) {
this._placement = 'top';
} else {
this._placement = 'bottom';
}
})
);
// listen to selection change
this.disposables.add(
this._selectionManager.slots.changed.on(() => {
const update = async () => {
const textSelection = rootComponent.selection.find('text');
const blockSelections = rootComponent.selection.filter('block');
// Should not re-render format bar when only cursor selection changed in edgeless
const cursorSelection = rootComponent.selection.find('cursor');
if (cursorSelection) {
if (!this._lastCursor) {
this._lastCursor = cursorSelection;
return;
}
if (!this._selectionEqual(cursorSelection, this._lastCursor)) {
this._lastCursor = cursorSelection;
return;
}
}
// We cannot use `host.getUpdateComplete()` here
// because it would cause excessive DOM queries, leading to UI jamming.
await nextTick();
if (textSelection) {
const block = this.host.view.getBlock(textSelection.blockId);
if (
!textSelection.isCollapsed() &&
block &&
block.model.role === 'content'
) {
this._displayType = 'text';
if (!rootComponent.std.range) return;
this.host.std.command
.chain()
.getTextSelection()
.getSelectedBlocks({
types: ['text'],
})
.inline(ctx => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) return;
this._selectedBlocks = selectedBlocks;
})
.run();
return;
}
this.reset();
return;
}
if (this.block && blockSelections.length > 0) {
this._displayType = 'block';
const selectedBlocks = blockSelections
.map(selection => {
const path = selection.blockId;
return this.block.host.view.getBlock(path);
})
.filter((el): el is BlockComponent => !!el);
this._selectedBlocks = selectedBlocks;
return;
}
this.reset();
};
update().catch(console.error);
})
);
this.disposables.addFromEvent(document, 'selectionchange', () => {
if (!this.host.event.active) return;
const databaseSelection = this.host.selection.find('database');
if (!databaseSelection) {
return;
}
const reset = () => {
this.reset();
this.requestUpdate();
};
const viewSelection = databaseSelection.viewSelection;
// check table selection
if (
viewSelection.type === 'table' &&
(viewSelection.selectionType !== 'area' || !viewSelection.isEditing)
)
return reset();
// check kanban selection
if (
(viewSelection.type === 'kanban' &&
viewSelection.selectionType !== 'cell') ||
!viewSelection.isEditing
)
return reset();
const range = this.nativeRange;
if (!range || range.collapsed) return reset();
this._displayType = 'native';
this.requestUpdate();
});
}
private _listenFloatingElement() {
const formatQuickBarElement = this.formatBarElement;
assertExists(formatQuickBarElement, 'format quick bar should exist');
const listenFloatingElement = (
getElement: () => ReferenceElement | void
) => {
const initialElement = getElement();
if (!initialElement) {
return;
}
assertExists(this._floatDisposables);
HoverController.globalAbortController?.abort();
this._floatDisposables.add(
autoUpdate(
initialElement,
formatQuickBarElement,
() => {
const element = getElement();
if (!element) return;
computePosition(element, formatQuickBarElement, {
placement: this._placement,
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
formatQuickBarElement.style.display = 'flex';
formatQuickBarElement.style.top = `${y}px`;
formatQuickBarElement.style.left = `${x}px`;
})
.catch(console.error);
},
{
// follow edgeless viewport update
animationFrame: true,
}
)
);
};
const getReferenceElementFromBlock = () => {
const firstBlock = this._selectedBlocks[0];
let rect = firstBlock?.getBoundingClientRect();
if (!rect) return;
this._selectedBlocks.forEach(el => {
const elRect = el.getBoundingClientRect();
if (elRect.top < rect.top) {
rect = new DOMRect(rect.left, elRect.top, rect.width, rect.bottom);
}
if (elRect.bottom > rect.bottom) {
rect = new DOMRect(rect.left, rect.top, rect.width, elRect.bottom);
}
if (elRect.left < rect.left) {
rect = new DOMRect(elRect.left, rect.top, rect.right, rect.bottom);
}
if (elRect.right > rect.right) {
rect = new DOMRect(rect.left, rect.top, elRect.right, rect.bottom);
}
});
return {
getBoundingClientRect: () => rect,
getClientRects: () =>
this._selectedBlocks.map(el => el.getBoundingClientRect()),
};
};
const getReferenceElementFromText = () => {
const range = this.nativeRange;
if (!range) {
return;
}
return {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
};
switch (this.displayType) {
case 'text':
case 'native':
return listenFloatingElement(getReferenceElementFromText);
case 'block':
return listenFloatingElement(getReferenceElementFromBlock);
default:
return;
}
}
private _selectionEqual(
target: BaseSelection | undefined,
current: BaseSelection | undefined
) {
if (target === current || (target && current && target.equals(current))) {
return true;
}
return false;
}
private _shouldDisplay() {
const readonly = this.doc.awarenessStore.isReadonly(
this.doc.blockCollection
);
const active = this.host.event.active;
if (readonly || !active) return false;
if (
this.displayType === 'block' &&
this._selectedBlocks?.[0]?.flavour === 'affine:surface-ref'
) {
return false;
}
if (this.displayType === 'block' && this._selectedBlocks.length === 1) {
const selectedBlock = this._selectedBlocks[0];
if (
!matchFlavours(selectedBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:image',
])
) {
return false;
}
}
if (this.displayType === 'none' || this._dragging) {
return false;
}
// if the selection is on an embed (ex. linked page), we should not display the format bar
if (this.displayType === 'text' && this._selectedBlocks.length === 1) {
const isEmbed = () => {
const [element] = this._selectedBlocks;
const richText = element.querySelector<RichText>('rich-text');
const inline = richText?.inlineEditor;
if (!richText || !inline) {
return false;
}
const range = inline.getInlineRange();
if (!range || range.length > 1) {
return false;
}
const deltas = inline.getDeltasByInlineRange(range);
if (deltas.length > 2) {
return false;
}
const delta = deltas?.[1]?.[0];
if (!delta) {
return false;
}
return inline.isEmbed(delta);
};
if (isEmbed()) {
return false;
}
}
// todo: refactor later that ai panel & format bar should not depend on each other
// do not display if AI panel is open
const rootBlockId = this.host.doc.root?.id;
const aiPanel = rootBlockId
? this.host.view.getWidget('affine-ai-panel-widget', rootBlockId)
: null;
// @ts-expect-error FIXME: ts error
if (aiPanel && aiPanel?.state !== 'hidden') {
return false;
}
return true;
}
addBlockTypeSwitch(config: {
flavour: BlockSuite.Flavour;
icon: ParagraphActionConfigItem['icon'];
type?: string;
name?: string;
}) {
const { flavour, type, icon } = config;
return this.addParagraphAction({
id: `${flavour}/${type ?? ''}`,
icon,
flavour,
name: config.name ?? camelCaseToWords(type ?? flavour),
action: chain => {
chain
.updateBlockType({
flavour,
props: type != null ? { type } : undefined,
})
.run();
},
});
}
addDivider() {
this.configItems.push({ type: 'divider' });
return this;
}
addHighlighterDropdown() {
this.configItems.push({ type: 'highlighter-dropdown' });
return this;
}
addInlineAction(config: Omit<InlineActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'inline-action' });
return this;
}
addParagraphAction(config: Omit<ParagraphActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'paragraph-action' });
return this;
}
addParagraphDropdown() {
this.configItems.push({ type: 'paragraph-dropdown' });
return this;
}
addRawConfigItems(configItems: FormatBarConfigItem[], index?: number) {
if (index === undefined) {
this.configItems.push(...configItems);
} else {
this.configItems.splice(index, 0, ...configItems);
}
return this;
}
addTextStyleToggle(config: {
icon: InlineActionConfigItem['icon'];
key: Exclude<
keyof AffineTextAttributes,
'color' | 'background' | 'reference'
>;
action: InlineActionConfigItem['action'];
}) {
const { key } = config;
return this.addInlineAction({
id: key,
name: camelCaseToWords(key),
icon: config.icon,
isActive: chain => {
const [result] = chain.isTextStyleActive({ key }).run();
return result;
},
action: config.action,
showWhen: chain => {
const [result] = isFormatSupported(chain).run();
return result;
},
});
}
clearConfig() {
this.configItems = [];
return this;
}
override connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
const rootComponent = this.block;
assertExists(rootComponent);
const widgets = rootComponent.widgets;
// check if the host use the format bar widget
if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) {
return;
}
// check if format bar widget support the host
if (rootComponent.model.flavour !== 'affine:page') {
console.error(
`format bar not support rootComponent: ${rootComponent.constructor.name} but its widgets has format bar`
);
return;
}
this._calculatePlacement();
if (this.configItems.length === 0) {
toolbarDefaultConfig(this);
}
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._abortController.abort();
this.reset();
this._lastCursor = undefined;
}
override render() {
if (!this._shouldDisplay()) {
return nothing;
}
const items = ConfigRenderer(this);
return html`
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
${items}
<editor-toolbar-separator></editor-toolbar-separator>
${toolbarMoreButton(this)}
</editor-toolbar>
`;
}
reset() {
this._displayType = 'none';
this._selectedBlocks = [];
}
override updated() {
if (!this._shouldDisplay()) {
if (this._floatDisposables) {
this._floatDisposables.dispose();
}
return;
}
this._floatDisposables = new DisposableGroup();
this._listenFloatingElement();
}
@state()
private accessor _displayType: 'text' | 'block' | 'native' | 'none' = 'none';
@state()
private accessor _dragging = false;
@state()
private accessor _selectedBlocks: BlockComponent[] = [];
@state()
accessor configItems: FormatBarConfigItem[] = [];
@query(`.${AFFINE_FORMAT_BAR_WIDGET}`)
accessor formatBarElement: HTMLElement | null = null;
}
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_FORMAT_BAR_WIDGET]: AffineFormatBarWidget;
}
}
@@ -0,0 +1,2 @@
export * from './config.js';
export { AffineFormatBarWidget } from './format-bar.js';
@@ -0,0 +1,56 @@
import { css } from 'lit';
import { scrollbarStyle } from '../../../_common/components/utils.js';
const paragraphButtonStyle = css`
.paragraph-button-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.paragraph-button-icon:is(:hover, :focus-visible, :active)
> svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.highlight-icon:is(:hover, :focus-visible, :active) > svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-panel {
max-height: 380px;
}
.highligh-panel-heading {
display: flex;
color: var(--affine-text-secondary-color);
padding: 4px;
}
editor-menu-content {
display: none;
position: absolute;
padding: 0;
z-index: var(--affine-z-index-popover);
--packed-height: 6px;
}
editor-menu-content > div[data-orientation='vertical'] {
padding: 8px;
overflow-y: auto;
}
${scrollbarStyle('editor-menu-content > div[data-orientation="vertical"]')}
`;
export const formatBarStyle = css`
.affine-format-bar-widget {
position: absolute;
display: none;
z-index: var(--affine-z-index-popover);
user-select: none;
}
${paragraphButtonStyle}
`;