Files
upstream-affine/blocksuite/affine/blocks/root/src/configs/toolbar.ts
upstream-mirror 50f788e799
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:31:07 +05:00

490 lines
13 KiB
TypeScript

import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
updateBlockAlign,
updateBlockType,
} from '@blocksuite/affine-block-note';
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
import { toast } from '@blocksuite/affine-components/toast';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
import {
deleteTextCommand,
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
isFormatSupported,
textFormatConfigs,
} from '@blocksuite/affine-inline-preset';
import {
EmbedLinkedDocBlockSchema,
EmbedSyncedDocBlockSchema,
type TextAlign,
} from '@blocksuite/affine-model';
import {
textAlignConfigs,
textConversionConfigs,
} from '@blocksuite/affine-rich-text';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
ToolbarAction,
ToolbarActionGenerator,
ToolbarActionGroup,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
ActionPlacement,
blockCommentToolbarButton,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import {
CopyIcon,
DatabaseTableViewIcon,
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
TeXIcon,
} from '@blocksuite/icons/lit';
import {
type BlockComponent,
BlockSelection,
BlockViewIdentifier,
} from '@blocksuite/std';
import { toDraftModel } from '@blocksuite/store';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
const conversionsActionGroup = {
id: 'a.conversions',
when: ({ chain }) => isFormatSupported(chain).run()[0],
generate({ chain }) {
const [ok, { selectedModels = [] }] = chain
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
.run();
// only support model with text
// TODO(@fundon): displays only in a single paragraph, `length === 1`.
const allowed = ok && selectedModels.filter(model => model.text).length > 0;
if (!allowed) return null;
const model = selectedModels[0];
const conversion =
textConversionConfigs.find(
({ flavour, type }) =>
flavour === model.flavour &&
(type ? 'type' in model.props && type === model.props.type : true)
) ?? textConversionConfigs[0];
const update = (flavour: string, type?: string) => {
chain
.pipe(updateBlockType, {
flavour,
...(type && { props: { type } }),
})
.run();
};
return {
content: html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Conversions"
.tooltip="${'Turn into'}"
>
${conversion.icon} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
item => item.name,
({ flavour, type, name, icon }) => html`
<editor-menu-action
aria-label=${name}
?data-selected=${conversion.name === name}
@click=${() => update(flavour, type)}
>
${icon}<span class="label">${name}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`,
};
},
} as const satisfies ToolbarActionGenerator;
const alignActionGroup = {
id: 'b.align',
when: ({ chain }) => isFormatSupported(chain).run()[0],
generate({ chain }) {
const [ok, { selectedModels = [] }] = chain
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
.run();
if (!ok) return null;
const alignment =
textAlignConfigs.find(
({ textAlign }) =>
textAlign ===
getMostCommonValue(
selectedModels.map(
({ props }) => props as { textAlign?: TextAlign }
),
'textAlign'
)
) ?? textAlignConfigs[0];
const update = (textAlign: TextAlign) => {
chain.pipe(updateBlockAlign, { textAlign }).run();
};
return {
content: html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Align" .tooltip="${'Align'}">
${alignment.icon} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
textAlignConfigs,
item => item.name,
({ textAlign, name, icon }) => html`
<editor-menu-action
aria-label=${name}
@click=${() => update(textAlign)}
>
${icon}<span class="label">${name}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`,
};
},
} as const satisfies ToolbarActionGenerator;
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.flatMap(
({ id, name, action, activeWhen, icon }, score) => {
const textAction: ToolbarAction = {
id,
icon,
score,
tooltip: name,
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
if (id !== 'underline') {
return [textAction];
}
return [
textAction,
{
id: 'inline-latex',
icon: TeXIcon(),
score: score + 0.5,
tooltip: 'Inline Equation',
run: ({ host }) => {
host.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(insertInlineLatex)
.run();
},
active: () => false,
},
];
}
),
} as const satisfies ToolbarActionGroup;
const highlightActionGroup = {
id: 'c.highlight',
when: ({ chain }) => isFormatSupported(chain).run()[0],
content({ chain }) {
const updateHighlight = (styles: HighlightType) => {
const payload = { styles };
chain
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain
.pipe(getBlockSelectionsCommand)
.pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
return html`
<affine-highlight-dropdown-menu
.updateHighlight=${updateHighlight}
></affine-highlight-dropdown-menu>
`;
},
} as const satisfies ToolbarAction;
const turnIntoDatabase = {
id: 'e.convert-to-database',
tooltip: 'Create Table',
icon: DatabaseTableViewIcon(),
when({ chain }) {
const middleware = (count = 0) => {
return (ctx: { selectedBlocks: BlockComponent[] }, 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 [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (ok) return true;
[ok] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return ok;
},
run({ host }) {
convertToDatabase(host, tableViewMeta.type);
},
} as const satisfies ToolbarAction;
const turnIntoLinkedDoc = {
id: 'f.convert-to-linked-doc',
tooltip: 'Create Linked Doc',
icon: LinkedPageIcon(),
when({ chain, std }) {
const supportFlavours = [
EmbedLinkedDocBlockSchema,
EmbedSyncedDocBlockSchema,
].map(schema => schema.model.flavour);
if (
supportFlavours.some(
flavour => !std.getOptional(BlockViewIdentifier(flavour))
)
)
return false;
const [ok, { selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.run();
return ok && Boolean(selectedModels?.length);
},
run({ chain, store, selection, std, track }) {
const [ok, { draftedModels, selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
if (!ok || !draftedModels || !selectedModels?.length) return;
selection.clear();
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
store,
draftedModels,
title
);
notifyDocCreated(std);
track('DocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
track('LinkedDocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
} as const satisfies ToolbarAction;
export const builtinToolbarConfig = {
actions: [
conversionsActionGroup,
alignActionGroup,
inlineTextActionGroup,
highlightActionGroup,
turnIntoDatabase,
turnIntoLinkedDoc,
{
id: 'g.comment',
...blockCommentToolbarButton,
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run({ chain, host }) {
const [ok] = chain
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
if (!ok) return;
toast(host, 'Copied to clipboard');
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run({ chain, store, selection }) {
store.captureSync();
const [ok, { selectedBlocks = [] }] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
mode: 'highest',
})
.run();
// If text selection exists, convert to block selection
if (ok && selectedBlocks.length) {
selection.setGroup(
'note',
selectedBlocks.map(block =>
selection.create(BlockSelection, {
blockId: block.model.id,
})
)
);
}
chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'image'],
mode: 'highest',
})
.pipe(duplicateSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
actions: [
{
id: 'delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run({ chain }) {
// removes text
const [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (ok) return;
// removes blocks
chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
],
} as const satisfies ToolbarModuleConfig;