mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 04:26:23 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
];
|
||||
+161
@@ -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}
|
||||
`;
|
||||
Reference in New Issue
Block a user