diff --git a/blocksuite/affine/block-attachment/src/attachment-block.ts b/blocksuite/affine/block-attachment/src/attachment-block.ts index 20b0737ad8..da9eef209c 100644 --- a/blocksuite/affine/block-attachment/src/attachment-block.ts +++ b/blocksuite/affine/block-attachment/src/attachment-block.ts @@ -1,6 +1,5 @@ import { getEmbedCardIcons } from '@blocksuite/affine-block-embed'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; -import { HoverController } from '@blocksuite/affine-components/hover'; import { AttachmentIcon16, getAttachmentFileIcon, @@ -16,19 +15,16 @@ import { ThemeProvider, } from '@blocksuite/affine-shared/services'; import { humanFileSize } from '@blocksuite/affine-shared/utils'; -import { BlockSelection, TextSelection } from '@blocksuite/block-std'; +import { BlockSelection } from '@blocksuite/block-std'; import { Slice } from '@blocksuite/store'; -import { flip, offset } from '@floating-ui/dom'; -import { html, nothing } from 'lit'; +import { html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { AttachmentOptionsTemplate } from './components/options.js'; -import { AttachmentEmbedProvider } from './embed.js'; -import { styles } from './styles.js'; -import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js'; +import { AttachmentEmbedProvider } from './embed'; +import { styles } from './styles'; +import { checkAttachmentBlob, downloadAttachmentBlob } from './utils'; @Peekable({ enableOn: ({ model }: AttachmentBlockComponent) => { @@ -42,43 +38,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { - const selection = this.host.selection; - const textSelection = selection.find(TextSelection); - if ( - !!textSelection && - (!!textSelection.to || !!textSelection.from.length) - ) { - return null; - } - - const blockSelections = selection.filter(BlockSelection); - if ( - blockSelections.length > 1 || - (blockSelections.length === 1 && - blockSelections[0].blockId !== this.blockId) - ) { - return null; - } - - return { - template: AttachmentOptionsTemplate({ - block: this, - model: this.model, - abortController, - }), - computePosition: { - referenceElement: this, - placement: 'top-start', - middleware: [flip(), offset(4)], - autoUpdate: true, - }, - }; - } - ); - blockDraggable = true; protected containerStyleMap = styleMap({ @@ -227,11 +186,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent +
${embedView ? html`
${embedView} diff --git a/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts b/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts index 04dd3429a0..71f857aa31 100644 --- a/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts +++ b/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts @@ -1,5 +1,4 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; -import type { HoverController } from '@blocksuite/affine-components/hover'; import { AttachmentBlockStyles } from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, @@ -13,8 +12,6 @@ import { AttachmentBlockComponent } from './attachment-block.js'; export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent( AttachmentBlockComponent ) { - protected override _whenHover: HoverController | null = null; - override blockDraggable = false; get slots() { diff --git a/blocksuite/affine/block-attachment/src/attachment-spec.ts b/blocksuite/affine/block-attachment/src/attachment-spec.ts index 6a9859dfa3..40df90ab0a 100644 --- a/blocksuite/affine/block-attachment/src/attachment-spec.ts +++ b/blocksuite/affine/block-attachment/src/attachment-spec.ts @@ -1,17 +1,26 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js'; import { AttachmentDropOption } from './attachment-service.js'; +import { builtinToolbarConfig } from './configs/toolbar'; import { AttachmentEmbedConfigExtension, AttachmentEmbedService, -} from './embed.js'; +} from './embed'; + +const flavour = AttachmentBlockSchema.model.flavour; export const AttachmentBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:attachment'), - BlockViewExtension('affine:attachment', model => { + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-edgeless-attachment` : literal`affine-attachment`; @@ -20,4 +29,8 @@ export const AttachmentBlockSpec: ExtensionType[] = [ AttachmentEmbedConfigExtension(), AttachmentEmbedService, AttachmentBlockNotionHtmlAdapterExtension, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ]; diff --git a/blocksuite/affine/block-attachment/src/components/config.ts b/blocksuite/affine/block-attachment/src/components/config.ts deleted file mode 100644 index 5bcb411aff..0000000000 --- a/blocksuite/affine/block-attachment/src/components/config.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - CopyIcon, - DeleteIcon, - DownloadIcon, - DuplicateIcon, - RefreshIcon, -} from '@blocksuite/affine-components/icons'; -import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; - -import { cloneAttachmentProperties } from '../utils.js'; -import type { AttachmentToolbarMoreMenuContext } from './context.js'; - -export const BUILT_IN_GROUPS: MenuItemGroup[] = - [ - { - type: 'clipboard', - items: [ - { - type: 'copy', - label: 'Copy', - icon: CopyIcon, - disabled: ({ doc }) => doc.readonly, - action: ctx => ctx.blockComponent.copy(), - }, - { - type: 'duplicate', - label: 'Duplicate', - icon: DuplicateIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - const model = blockComponent.model; - const prop: { flavour: 'affine:attachment' } = { - flavour: 'affine:attachment', - ...cloneAttachmentProperties(model), - }; - doc.addSiblingBlocks(model, [prop]); - close(); - }, - }, - { - type: 'reload', - label: 'Reload', - icon: RefreshIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, close }) => { - blockComponent.refreshData(); - close(); - }, - }, - { - type: 'download', - label: 'Download', - icon: DownloadIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, close }) => { - blockComponent.download(); - close(); - }, - }, - ], - }, - { - type: 'delete', - items: [ - { - type: 'delete', - label: 'Delete', - icon: DeleteIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - doc.deleteBlock(blockComponent.model); - close(); - }, - }, - ], - }, - ]; diff --git a/blocksuite/affine/block-attachment/src/components/context.ts b/blocksuite/affine/block-attachment/src/components/context.ts deleted file mode 100644 index f3697fb094..0000000000 --- a/blocksuite/affine/block-attachment/src/components/context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MenuContext } from '@blocksuite/affine-components/toolbar'; - -import type { AttachmentBlockComponent } from '../attachment-block.js'; - -export class AttachmentToolbarMoreMenuContext extends MenuContext { - override close = () => { - this.abortController.abort(); - }; - - get doc() { - return this.blockComponent.doc; - } - - get host() { - return this.blockComponent.host; - } - - get selectedBlockModels() { - if (this.blockComponent.model) return [this.blockComponent.model]; - return []; - } - - get std() { - return this.blockComponent.std; - } - - constructor( - public blockComponent: AttachmentBlockComponent, - public abortController: AbortController - ) { - super(); - } - - isEmpty() { - return false; - } - - isMultiple() { - return false; - } - - isSingle() { - return true; - } -} diff --git a/blocksuite/affine/block-attachment/src/components/options.ts b/blocksuite/affine/block-attachment/src/components/options.ts deleted file mode 100644 index f23b43db99..0000000000 --- a/blocksuite/affine/block-attachment/src/components/options.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - CaptionIcon, - DownloadIcon, - EditIcon, -} from '@blocksuite/affine-components/icons'; -import { createLitPortal } from '@blocksuite/affine-components/portal'; -import { - cloneGroups, - getMoreMenuConfig, - renderGroups, - renderToolbarSeparator, -} from '@blocksuite/affine-components/toolbar'; -import { - type AttachmentBlockModel, - defaultAttachmentProps, -} from '@blocksuite/affine-model'; -import { - EMBED_CARD_HEIGHT, - EMBED_CARD_WIDTH, -} from '@blocksuite/affine-shared/consts'; -import { Bound } from '@blocksuite/global/gfx'; -import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit'; -import { flip, offset } from '@floating-ui/dom'; -import { html, nothing } from 'lit'; -import { join } from 'lit/directives/join.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import type { AttachmentBlockComponent } from '../attachment-block.js'; -import { BUILT_IN_GROUPS } from './config.js'; -import { AttachmentToolbarMoreMenuContext } from './context.js'; -import { RenameModal } from './rename-model.js'; -import { styles } from './styles.js'; - -export function attachmentViewToggleMenu({ - block, - callback, -}: { - block: AttachmentBlockComponent; - callback?: () => void; -}) { - const model = block.model; - const readonly = model.doc.readonly; - const embedded = model.embed; - const viewType = embedded ? 'embed' : 'card'; - const viewActions = [ - { - type: 'card', - label: 'Card view', - disabled: readonly || !embedded, - action: () => { - const style = defaultAttachmentProps.style!; - const width = EMBED_CARD_WIDTH[style]; - const height = EMBED_CARD_HEIGHT[style]; - const bound = Bound.deserialize(model.xywh); - bound.w = width; - bound.h = height; - model.doc.updateBlock(model, { - style, - embed: false, - xywh: bound.serialize(), - }); - callback?.(); - }, - }, - { - type: 'embed', - label: 'Embed view', - disabled: readonly || embedded || !block.embedded(), - action: () => { - block.convertTo(); - callback?.(); - }, - }, - ]; - - return html` - -
- ${viewType} - view -
- ${ArrowDownSmallIcon({ width: '16px', height: '16px' })} - - `} - > -
- ${repeat( - viewActions, - button => button.type, - ({ type, label, action, disabled }) => html` - - ${label} - - ` - )} -
-
- `; -} - -export function AttachmentOptionsTemplate({ - block, - model, - abortController, -}: { - block: AttachmentBlockComponent; - model: AttachmentBlockModel; - abortController: AbortController; -}) { - const std = block.std; - const editorHost = block.host; - const readonly = model.doc.readonly; - const context = new AttachmentToolbarMoreMenuContext(block, abortController); - const groups = getMoreMenuConfig(std).configure(cloneGroups(BUILT_IN_GROUPS)); - const moreMenuActions = renderGroups(groups, context); - - const buttons = [ - // preview - // html` - // - // ${ViewIcon} - // - // `, - - readonly - ? nothing - : html` - { - abortController.abort(); - const renameAbortController = new AbortController(); - createLitPortal({ - template: RenameModal({ - model, - editorHost, - abortController: renameAbortController, - }), - computePosition: { - referenceElement: block, - placement: 'top-start', - middleware: [flip(), offset(4)], - // It has a overlay mask, so we don't need to update the position. - // autoUpdate: true, - }, - abortController: renameAbortController, - }); - }} - > - ${EditIcon} - - `, - - attachmentViewToggleMenu({ - block, - callback: () => abortController.abort(), - }), - - readonly - ? nothing - : html` - block.download()} - > - ${DownloadIcon} - - `, - - readonly - ? nothing - : html` - block.captionEditor?.show()} - > - ${CaptionIcon} - - `, - - html` - - ${MoreVerticalIcon()} - - `} - > -
- ${moreMenuActions} -
-
- `, - ]; - - return html` - - - ${join( - buttons.filter(button => button !== nothing), - renderToolbarSeparator - )} - - `; -} diff --git a/blocksuite/affine/block-attachment/src/components/rename-model.ts b/blocksuite/affine/block-attachment/src/components/rename-model.ts index 8613ea16e2..8957e4da87 100644 --- a/blocksuite/affine/block-attachment/src/components/rename-model.ts +++ b/blocksuite/affine/block-attachment/src/components/rename-model.ts @@ -5,7 +5,7 @@ import type { EditorHost } from '@blocksuite/block-std'; import { html } from 'lit'; import { createRef, ref } from 'lit/directives/ref.js'; -import { renameStyles } from './styles.js'; +import { renameStyles } from './styles'; export const RenameModal = ({ editorHost, @@ -34,6 +34,7 @@ export const RenameModal = ({ let fileName = includeExtension ? nameWithoutExtension : originalName; const extension = includeExtension ? originalExtension : ''; + const abort = () => abortController.abort(); const onConfirm = () => { const newFileName = fileName + extension; if (!newFileName) { @@ -43,7 +44,7 @@ export const RenameModal = ({ model.doc.updateBlock(model, { name: newFileName, }); - abortController.abort(); + abort(); }; const onInput = (e: InputEvent) => { fileName = (e.target as HTMLInputElement).value; @@ -52,7 +53,7 @@ export const RenameModal = ({ e.stopPropagation(); if (e.key === 'Escape' && !e.isComposing) { - abortController.abort(); + abort(); return; } if (e.key === 'Enter' && !e.isComposing) { @@ -65,10 +66,7 @@ export const RenameModal = ({ -
+
icon-button { - display: flex; - align-items: center; - padding: 8px; - gap: 8px; - } - .affine-attachment-options-more-container > icon-button[hidden] { - display: none; - } - - .affine-attachment-options-more-container > icon-button:hover.danger { - background: var(--affine-background-error-color); - color: var(--affine-error-color); - } - .affine-attachment-options-more-container > icon-button:hover.danger > svg { - color: var(--affine-error-color); - } -`; - export const styles = css` :host { z-index: 1; diff --git a/blocksuite/affine/block-attachment/src/configs/toolbar.ts b/blocksuite/affine/block-attachment/src/configs/toolbar.ts new file mode 100644 index 0000000000..53cfe9d88b --- /dev/null +++ b/blocksuite/affine/block-attachment/src/configs/toolbar.ts @@ -0,0 +1,278 @@ +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + AttachmentBlockModel, + defaultAttachmentProps, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarContext, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { BlockSelection, SurfaceSelection } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/gfx'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, + EditIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import type { SelectionConstructor } from '@blocksuite/store'; +import { flip, offset } from '@floating-ui/dom'; +import { computed } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; + +import { AttachmentBlockComponent } from '../attachment-block'; +import { RenameModal } from '../components/rename-model'; +import { AttachmentEmbedProvider } from '../embed'; +import { cloneAttachmentProperties } from '../utils'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'attachment', + type: 'card view', +}; + +const createAttachmentViewDropdownMenuWith = ( + t: T +) => { + return { + id: 'b.conversions', + actions: [ + { + id: 'card', + label: 'Card view', + run(ctx) { + const model = ctx.getCurrentModelByType(t, AttachmentBlockModel); + if (!model) return; + + const style = defaultAttachmentProps.style!; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bound = Bound.deserialize(model.xywh); + bound.w = width; + bound.h = height; + + ctx.store.updateBlock(model, { + style, + embed: false, + xywh: bound.serialize(), + }); + }, + }, + { + id: 'embed', + label: 'Embed view', + run(ctx) { + const model = ctx.getCurrentModelByType(t, AttachmentBlockModel); + if (!model) return; + + // Clears + ctx.reset(); + ctx.select('note'); + + ctx.std.get(AttachmentEmbedProvider).convertTo(model); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType(t, AttachmentBlockModel); + if (!model) return null; + + const embedProvider = ctx.std.get(AttachmentEmbedProvider); + const actions = this.actions.map(action => ({ ...action })); + const viewType$ = computed(() => { + const [cardAction, embedAction] = actions; + const embed = model.embed$.value ?? false; + + cardAction.disabled = !embed; + embedAction.disabled = embed && embedProvider.embedded(model); + + return embed ? embedAction.label : cardAction.label; + }); + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup; +}; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'a.rename', + content(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + if (!component) return null; + + const abortController = new AbortController(); + abortController.signal.onabort = () => cx.show(); + + return html` + { + cx.hide(); + + createLitPortal({ + template: RenameModal({ + model: component.model, + editorHost: cx.host, + abortController, + }), + computePosition: { + referenceElement: component, + placement: 'top-start', + middleware: [flip(), offset(4)], + }, + abortController, + }); + }} + > + ${EditIcon()} + + `; + }, + }, + createAttachmentViewDropdownMenuWith(BlockSelection), + { + id: 'c.download', + tooltip: 'Download', + icon: DownloadIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.download(); + }, + }, + { + id: 'd.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + // TODO(@fundon): unify `clone` method + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.copy(); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + )?.model; + if (!model) return; + + // TODO(@fundon): unify `duplicate` method + ctx.store.addSiblingBlocks(model, [ + { + flavour: model.flavour, + ...cloneAttachmentProperties(model), + }, + ]); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'b.refresh', + label: 'Reload', + icon: ResetIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.refreshData(); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; + +export const attachmentViewDropdownMenu = (ctx: ToolbarContext) => { + return createAttachmentViewDropdownMenuWith(SurfaceSelection).content(ctx); +}; diff --git a/blocksuite/affine/block-attachment/src/index.ts b/blocksuite/affine/block-attachment/src/index.ts index 4298e7a67f..0cec7006a5 100644 --- a/blocksuite/affine/block-attachment/src/index.ts +++ b/blocksuite/affine/block-attachment/src/index.ts @@ -2,7 +2,7 @@ export * from './adapters/notion-html'; export * from './attachment-block'; export * from './attachment-service'; export * from './attachment-spec'; -export { attachmentViewToggleMenu } from './components/options'; +export { attachmentViewDropdownMenu } from './configs/toolbar'; export { type AttachmentEmbedConfig, AttachmentEmbedConfigIdentifier, diff --git a/blocksuite/affine/block-bookmark/package.json b/blocksuite/affine/block-bookmark/package.json index 2fcd52653d..e3109fc204 100644 --- a/blocksuite/affine/block-bookmark/package.json +++ b/blocksuite/affine/block-bookmark/package.json @@ -22,12 +22,12 @@ "@blocksuite/icons": "^2.2.1", "@blocksuite/inline": "workspace:*", "@blocksuite/store": "workspace:*", - "@floating-ui/dom": "^1.6.13", "@lit/context": "^1.1.2", "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.12", "lit": "^3.2.0", "minimatch": "^10.0.1", + "yjs": "^13.6.23", "zod": "^3.23.8" }, "exports": { diff --git a/blocksuite/affine/block-bookmark/src/bookmark-spec.ts b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts index 0bd93b6ae3..3467c4d277 100644 --- a/blocksuite/affine/block-bookmark/src/bookmark-spec.ts +++ b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts @@ -1,15 +1,28 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { BookmarkBlockAdapterExtensions } from './adapters/extension.js'; +import { BookmarkBlockAdapterExtensions } from './adapters/extension'; +import { builtinToolbarConfig } from './configs/toolbar'; + +const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:bookmark'), - BlockViewExtension('affine:bookmark', model => { + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-edgeless-bookmark` : literal`affine-bookmark`; }), BookmarkBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/block-bookmark/src/configs/toolbar.ts b/blocksuite/affine/block-bookmark/src/configs/toolbar.ts new file mode 100644 index 0000000000..fbfbc1def9 --- /dev/null +++ b/blocksuite/affine/block-bookmark/src/configs/toolbar.ts @@ -0,0 +1,324 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { BookmarkBlockModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + EmbedOptionProvider, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { Slice, Text } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; +import * as Y from 'yjs'; + +import { BookmarkBlockComponent } from '../bookmark-block'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'bookmark', + type: 'card view', +}; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'a.preview', + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return null; + + const { url } = model; + + return html``; + }, + }, + { + id: 'b.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + run(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return; + + const { title, caption, url, parent } = model; + const index = parent?.children.indexOf(model); + + const yText = new Y.Text(); + const insert = title || caption || url; + yText.insert(0, insert); + yText.format(0, insert.length, { link: url }); + + const text = new Text(yText); + + ctx.store.addBlock('affine:paragraph', { text }, parent, index); + + ctx.store.deleteBlock(model); + + // Clears + ctx.reset(); + ctx.select('note'); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'inline view', + }); + }, + }, + { + id: 'card', + label: 'Card view', + disabled: true, + }, + { + id: 'embed', + label: 'Embed view', + disabled(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return true; + + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(model.url); + + return options?.viewType !== 'embed'; + }, + run(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return; + + const { caption, url, style, parent } = model; + const index = parent?.children.indexOf(model); + + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (!options) return; + + const { flavour, styles } = options; + + const newStyle = styles.includes(style) + ? style + : styles.find(s => s !== 'vertical' && s !== 'cube'); + + const blockId = ctx.store.addBlock( + flavour, + { + url, + caption, + style: newStyle, + }, + parent, + index + ); + + ctx.store.deleteBlock(model); + + // Selects new block + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'c.style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + BookmarkBlockModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: action.id, + }); + }, + })) satisfies ToolbarAction[]; + + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedCardStyleSelector', { + ...trackBaseProps, + control: 'switch card style', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'd.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + BookmarkBlockComponent + ); + component?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'b.refresh', + label: 'Reload', + icon: ResetIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + BookmarkBlockComponent + ); + component?.refreshData(); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/configs/toolbar.ts b/blocksuite/affine/block-embed/src/configs/toolbar.ts new file mode 100644 index 0000000000..0257858980 --- /dev/null +++ b/blocksuite/affine/block-embed/src/configs/toolbar.ts @@ -0,0 +1,404 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { + BookmarkStyles, + EmbedGithubModel, + isExternalEmbedModel, +} from '@blocksuite/affine-model'; +import { + ActionPlacement, + EmbedOptionProvider, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { Slice, Text } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; +import * as Y from 'yjs'; + +import type { EmbedFigmaBlockComponent } from '../embed-figma-block'; +import type { EmbedGithubBlockComponent } from '../embed-github-block'; +import type { EmbedLoomBlockComponent } from '../embed-loom-block'; +import type { EmbedYoutubeBlockComponent } from '../embed-youtube-block'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'link', + type: 'card view', +}; + +// External embed blocks +export function createBuiltinToolbarConfigForExternal( + klass: + | typeof EmbedGithubBlockComponent + | typeof EmbedFigmaBlockComponent + | typeof EmbedLoomBlockComponent + | typeof EmbedYoutubeBlockComponent +) { + return { + actions: [ + { + id: 'a.preview', + content(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return null; + + const { url } = model; + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (options?.viewType !== 'card') return null; + + return html``; + }, + }, + { + id: 'b.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return; + + const { title, caption, url: link, parent } = model; + const index = parent?.children.indexOf(model); + + const yText = new Y.Text(); + const insert = title || caption || link; + yText.insert(0, insert); + yText.format(0, insert.length, { link }); + + const text = new Text(yText); + + ctx.store.addBlock('affine:paragraph', { text }, parent, index); + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'inline view', + }); + }, + }, + { + id: 'card', + label: 'Card view', + disabled(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return true; + + const { url } = model; + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + return options?.viewType === 'card'; + }, + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return; + + const { url, caption, parent } = model; + const index = parent?.children.indexOf(model); + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + let { style } = model; + let flavour = 'affine:bookmark'; + + if (options?.viewType === 'card') { + flavour = options.flavour; + if (!options.styles.includes(style)) { + style = options.styles[0]; + } + } else { + style = + BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ?? + BookmarkStyles[1]; + } + + const blockId = ctx.store.addBlock( + flavour, + { url, caption, style }, + parent, + index + ); + + ctx.store.deleteBlock(model); + + // Selects new block + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'card view', + }); + }, + }, + { + id: 'embed', + label: 'Embed view', + disabled(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return false; + + const { url } = model; + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + return options?.viewType === 'embed'; + }, + when(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return false; + + const { url } = model; + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + return options?.viewType === 'embed'; + }, + run(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return; + + const { url, caption, parent } = model; + const index = parent?.children.indexOf(model); + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (options?.viewType !== 'embed') return; + + const { flavour, styles } = options; + let { style } = model; + + if (!styles.includes(style)) { + style = + styles.find(s => s !== 'vertical' && s !== 'cube') ?? + styles[0]; + } + + const blockId = ctx.store.addBlock( + flavour, + { url, caption, style }, + parent, + index + ); + + ctx.store.deleteBlock(model); + + // Selects new block + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const model = ctx.getCurrentBlockBy(BlockSelection)?.model; + if (!model || !isExternalEmbedModel(model)) return null; + + const { url } = model; + const viewType = + ctx.std.get(EmbedOptionProvider).getEmbedBlockOptions(url) + ?.viewType ?? 'card'; + const actions = this.actions.map(action => ({ ...action })); + const viewType$ = signal( + `${viewType === 'card' ? 'Card' : 'Embed'} view` + ); + + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'c.style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + EmbedGithubModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: action.id, + }); + }, + })) satisfies ToolbarAction[]; + + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedCardStyleSelector', { + ...trackBaseProps, + control: 'switch card style', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'd.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + klass + ); + if (!component) return; + + component.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model || !isExternalEmbedModel(model)) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model || !isExternalEmbedModel(model)) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'b.reload', + label: 'Reload', + icon: ResetIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + klass + ); + component?.refreshData(); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model || !isExternalEmbedModel(model)) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], + } as const satisfies ToolbarModuleConfig; +} diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts index 680c1aeb19..1b91f3a0aa 100644 --- a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts @@ -1,17 +1,31 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedFigmaBlockOptionConfig } from './embed-figma-service.js'; +import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension'; +import { EmbedFigmaBlockComponent } from './embed-figma-block'; +import { EmbedFigmaBlockOptionConfig } from './embed-figma-service'; + +const flavour = EmbedFigmaBlockSchema.model.flavour; export const EmbedFigmaBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-figma'), - BlockViewExtension('affine:embed-figma', model => { + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-figma-block` : literal`affine-embed-figma-block`; }), EmbedFigmaBlockAdapterExtensions, EmbedFigmaBlockOptionConfig, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: createBuiltinToolbarConfigForExternal(EmbedFigmaBlockComponent), + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts index 90635abbb1..a9655b2a33 100644 --- a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts @@ -1,21 +1,35 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedGithubBlockAdapterExtensions } from './adapters/extension.js'; +import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedGithubBlockAdapterExtensions } from './adapters/extension'; +import { EmbedGithubBlockComponent } from './embed-github-block'; import { EmbedGithubBlockOptionConfig, EmbedGithubBlockService, -} from './embed-github-service.js'; +} from './embed-github-service'; + +const flavour = EmbedGithubBlockSchema.model.flavour; export const EmbedGithubBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-github'), + FlavourExtension(flavour), EmbedGithubBlockService, - BlockViewExtension('affine:embed-github', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-github-block` : literal`affine-embed-github-block`; }), EmbedGithubBlockAdapterExtensions, EmbedGithubBlockOptionConfig, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: createBuiltinToolbarConfigForExternal(EmbedGithubBlockComponent), + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-html-block/configs/toolbar.ts b/blocksuite/affine/block-embed/src/embed-html-block/configs/toolbar.ts new file mode 100644 index 0000000000..8292585a45 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/configs/toolbar.ts @@ -0,0 +1,167 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { EmbedHtmlModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ExpandFullIcon, +} from '@blocksuite/icons/lit'; +import { Slice } from '@blocksuite/store'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; + +import { EmbedHtmlBlockComponent } from '../embed-html-block'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'html', + type: 'card view', +}; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'a.open-doc', + icon: ExpandFullIcon(), + tooltip: 'Open this doc', + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedHtmlBlockComponent + ); + component?.open(); + }, + }, + { + id: 'b.style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType(BlockSelection, EmbedHtmlModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: action.id, + }); + }, + })); + + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedCardStyleSelector', { + ...trackBaseProps, + control: 'switch card style', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'c.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedHtmlBlockComponent + ); + component?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts index 8f9d6bd5d9..8ff079907b 100644 --- a/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts @@ -1,11 +1,24 @@ -import { BlockViewExtension } from '@blocksuite/block-std'; +import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; +import { builtinToolbarConfig } from './configs/toolbar'; + +const flavour = EmbedHtmlBlockSchema.model.flavour; + export const EmbedHtmlBlockSpec: ExtensionType[] = [ - BlockViewExtension('affine:embed-html', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-html-block` : literal`affine-embed-html-block`; }), + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ]; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/configs/toolbar.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/configs/toolbar.ts new file mode 100644 index 0000000000..503a8bd8b4 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/configs/toolbar.ts @@ -0,0 +1,276 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { EmbedLinkedDocModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { + getBlockProps, + referenceToNode, +} from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, +} from '@blocksuite/icons/lit'; +import { Slice } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; + +import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'linked doc', + type: 'card view', +}; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'a.doc-title', + content(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedLinkedDocBlockComponent + ); + if (!component) return null; + + const model = component.model; + if (!model.title) return null; + + const originalTitle = + ctx.workspace.getDoc(model.pageId)?.meta?.title || 'Untitled'; + + return html` component.open({ event })} + >`; + }, + }, + { + id: 'b.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedLinkedDocBlockComponent + ); + component?.covertToInline(); + + // Clears + ctx.select('note'); + ctx.reset(); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'inline view', + }); + }, + }, + { + id: 'card', + label: 'Card view', + disabled: true, + }, + { + id: 'embed', + label: 'Embed view', + disabled(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedLinkedDocBlockComponent + ); + if (!component) return true; + + if (component.closest('affine-embed-synced-doc-block')) return true; + + const model = component.model; + + // same doc + if (model.pageId === ctx.store.id) return true; + + // linking to block + if (referenceToNode(model)) return true; + + return false; + }, + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedLinkedDocBlockComponent + ); + component?.convertToEmbed(); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + EmbedLinkedDocModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'c.style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + EmbedLinkedDocModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: action.id, + }); + }, + })) satisfies ToolbarAction[]; + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedCardStyleSelector', { + ...trackBaseProps, + control: 'switch card style', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'd.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedLinkedDocBlockComponent + ); + component?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts index bce117957b..e60c8efbf7 100644 --- a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts @@ -109,7 +109,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent { - const selectionManager = this.host.selection; + const selectionManager = this.std.selection; const blockSelection = selectionManager.create(BlockSelection, { blockId: this.blockId, }); @@ -129,15 +129,10 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent { if (this._referenceToNode) return; - const { doc, caption } = this.model; + const { doc, caption, parent } = this.model; + const index = parent?.children.indexOf(this.model); - const parent = doc.getParent(this.model); - if (!parent) { - return; - } - const index = parent.children.indexOf(this.model); - - doc.addBlock( + const blockId = doc.addBlock( 'affine:embed-synced-doc', { caption, @@ -147,8 +142,11 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent { diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts index 128171c90d..6be8956c32 100644 --- a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts @@ -1,14 +1,26 @@ -import { BlockViewExtension } from '@blocksuite/block-std'; +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension.js'; +import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension'; +import { builtinToolbarConfig } from './configs/toolbar'; + +const flavour = EmbedLinkedDocBlockSchema.model.flavour; export const EmbedLinkedDocBlockSpec: ExtensionType[] = [ - BlockViewExtension('affine:embed-linked-doc', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-linked-doc-block` : literal`affine-embed-linked-doc-block`; }), EmbedLinkedDocBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts index 4a1668a0f3..8d0f878605 100644 --- a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts @@ -1,21 +1,35 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedLoomBlockAdapterExtensions } from './adapters/extension.js'; +import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedLoomBlockAdapterExtensions } from './adapters/extension'; +import { EmbedLoomBlockComponent } from './embed-loom-block'; import { EmbedLoomBlockOptionConfig, EmbedLoomBlockService, -} from './embed-loom-service.js'; +} from './embed-loom-service'; + +const flavour = EmbedLoomBlockSchema.model.flavour; export const EmbedLoomBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-loom'), + FlavourExtension(flavour), EmbedLoomBlockService, - BlockViewExtension('affine:embed-loom', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-loom-block` : literal`affine-embed-loom-block`; }), EmbedLoomBlockAdapterExtensions, EmbedLoomBlockOptionConfig, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: createBuiltinToolbarConfigForExternal(EmbedLoomBlockComponent), + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/configs/toolbar.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/configs/toolbar.ts new file mode 100644 index 0000000000..d57a11c809 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/configs/toolbar.ts @@ -0,0 +1,259 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { EmbedSyncedDocModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type OpenDocMode, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarContext, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + ArrowDownSmallIcon, + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ExpandFullIcon, + OpenInNewIcon, +} from '@blocksuite/icons/lit'; +import { Slice } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block'; + +const trackBaseProps = { + segment: 'doc', + page: 'doc editor', + module: 'toolbar', + category: 'linked doc', + type: 'embed view', +}; + +export const builtinToolbarConfig = { + actions: [ + { + placement: ActionPlacement.Start, + id: 'A.open-doc', + actions: [ + { + id: 'open-in-active-view', + label: 'Open this doc', + icon: ExpandFullIcon(), + }, + ], + content(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedSyncedDocBlockComponent + ); + if (!component) return null; + + const actions = this.actions + .map(action => { + const shouldOpenInActiveView = action.id === 'open-in-active-view'; + const allowed = + typeof action.when === 'function' + ? action.when(ctx) + : (action.when ?? true); + return { + ...action, + disabled: shouldOpenInActiveView + ? component.model.pageId === ctx.store.id + : false, + when: allowed, + run: (_ctx: ToolbarContext) => + component.open({ + openMode: action.id as OpenDocMode, + }), + }; + }) + .filter(action => { + if (typeof action.when === 'function') return action.when(ctx); + return action.when ?? true; + }); + + return html` + + ${OpenInNewIcon()} ${ArrowDownSmallIcon()} + + `} + > +
+ ${repeat( + actions, + action => action.id, + ({ label, icon, run, disabled }) => html` + run?.(ctx)} + > + ${icon}${label} + + ` + )} +
+
+ `; + }, + } satisfies ToolbarActionGroup, + { + id: 'a.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedSyncedDocBlockComponent + ); + component?.covertToInline(); + + // Clears + ctx.reset(); + ctx.select('note'); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'inline view', + }); + }, + }, + { + id: 'card', + label: 'Card view', + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedSyncedDocBlockComponent + ); + component?.convertToCard(); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'card view', + }); + }, + }, + { + id: 'embed', + label: 'Embed view', + disabled: true, + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType( + BlockSelection, + EmbedSyncedDocModel + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'b.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const component = ctx.getCurrentBlockComponentBy( + BlockSelection, + EmbedSyncedDocBlockComponent + ); + component?.captionEditor?.show(); + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelBy(BlockSelection); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts index e88a18acad..53ffd239cc 100644 --- a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts @@ -291,15 +291,18 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent { @@ -469,7 +472,7 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-synced-doc-block` : literal`affine-embed-synced-doc-block`; }), EmbedSyncedDocBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts index f10e9fb94f..dfa9466001 100644 --- a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts @@ -1,21 +1,35 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension.js'; +import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension'; +import { EmbedYoutubeBlockComponent } from './embed-youtube-block'; import { EmbedYoutubeBlockOptionConfig, EmbedYoutubeBlockService, -} from './embed-youtube-service.js'; +} from './embed-youtube-service'; + +const flavour = EmbedYoutubeBlockSchema.model.flavour; export const EmbedYoutubeBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-youtube'), + FlavourExtension(flavour), EmbedYoutubeBlockService, - BlockViewExtension('affine:embed-youtube', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-youtube-block` : literal`affine-embed-youtube-block`; }), EmbedYoutubeBlockAdapterExtensions, EmbedYoutubeBlockOptionConfig, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: createBuiltinToolbarConfigForExternal(EmbedYoutubeBlockComponent), + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/index.ts b/blocksuite/affine/block-embed/src/index.ts index 3ceed9ecd5..7e87f978cd 100644 --- a/blocksuite/affine/block-embed/src/index.ts +++ b/blocksuite/affine/block-embed/src/index.ts @@ -9,11 +9,14 @@ import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block'; import { EmbedYoutubeBlockSpec } from './embed-youtube-block'; export const EmbedExtensions: ExtensionType[] = [ + // External embed blocks EmbedFigmaBlockSpec, EmbedGithubBlockSpec, - EmbedHtmlBlockSpec, EmbedLoomBlockSpec, EmbedYoutubeBlockSpec, + + // Internal embed blocks + EmbedHtmlBlockSpec, EmbedLinkedDocBlockSpec, EmbedSyncedDocBlockSpec, ].flat(); @@ -22,7 +25,7 @@ export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html'; export { createEmbedBlockMarkdownAdapterMatcher } from './common/adapters/markdown'; export { createEmbedBlockPlainTextAdapterMatcher } from './common/adapters/plain-text'; export { EmbedBlockComponent } from './common/embed-block-element'; -export { insertEmbedCard } from './common/insert-embed-card.js'; +export { insertEmbedCard } from './common/insert-embed-card'; export * from './common/render-linked-doc'; export { toEdgelessEmbedBlock } from './common/to-edgeless-embed-block'; export * from './common/utils'; @@ -33,3 +36,4 @@ export * from './embed-linked-doc-block'; export * from './embed-loom-block'; export * from './embed-synced-doc-block'; export * from './embed-youtube-block'; +export * from './types'; diff --git a/blocksuite/affine/block-embed/src/types.ts b/blocksuite/affine/block-embed/src/types.ts new file mode 100644 index 0000000000..a8cf14f8cd --- /dev/null +++ b/blocksuite/affine/block-embed/src/types.ts @@ -0,0 +1,33 @@ +import type { BlockComponent } from '@blocksuite/block-std'; + +import { EmbedFigmaBlockComponent } from './embed-figma-block'; +import { EmbedGithubBlockComponent } from './embed-github-block'; +import type { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block'; +import { EmbedLoomBlockComponent } from './embed-loom-block'; +import type { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block'; +import { EmbedYoutubeBlockComponent } from './embed-youtube-block'; + +export type ExternalEmbedBlockComponent = + | EmbedFigmaBlockComponent + | EmbedGithubBlockComponent + | EmbedLoomBlockComponent + | EmbedYoutubeBlockComponent; + +export type InternalEmbedBlockComponent = + | EmbedLinkedDocBlockComponent + | EmbedSyncedDocBlockComponent; + +export type LinkableEmbedBlockComponent = + | ExternalEmbedBlockComponent + | InternalEmbedBlockComponent; + +export function isExternalEmbedBlockComponent( + block: BlockComponent +): block is ExternalEmbedBlockComponent { + return ( + block instanceof EmbedFigmaBlockComponent || + block instanceof EmbedGithubBlockComponent || + block instanceof EmbedLoomBlockComponent || + block instanceof EmbedYoutubeBlockComponent + ); +} diff --git a/blocksuite/affine/block-image/src/image-spec.ts b/blocksuite/affine/block-image/src/image-spec.ts index 9457153154..ec020e9a3c 100644 --- a/blocksuite/affine/block-image/src/image-spec.ts +++ b/blocksuite/affine/block-image/src/image-spec.ts @@ -6,9 +6,11 @@ import { import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { ImageBlockAdapterExtensions } from './adapters/extension.js'; -import { ImageProxyService } from './image-proxy-service.js'; -import { ImageBlockService, ImageDropOption } from './image-service.js'; +import { ImageBlockAdapterExtensions } from './adapters/extension'; +import { ImageProxyService } from './image-proxy-service'; +import { ImageBlockService, ImageDropOption } from './image-service'; + +const flavour = 'affine:image'; export const imageToolbarWidget = WidgetViewExtension( 'affine:image', @@ -17,9 +19,9 @@ export const imageToolbarWidget = WidgetViewExtension( ); export const ImageBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:image'), + FlavourExtension(flavour), ImageBlockService, - BlockViewExtension('affine:image', model => { + BlockViewExtension(flavour, model => { const parent = model.doc.getParent(model.id); if (parent?.flavour === 'affine:surface') { diff --git a/blocksuite/affine/block-note/package.json b/blocksuite/affine/block-note/package.json index 84ce7bd6ec..be4652247b 100644 --- a/blocksuite/affine/block-note/package.json +++ b/blocksuite/affine/block-note/package.json @@ -13,17 +13,18 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine-block-database": "workspace:*", "@blocksuite/affine-block-embed": "workspace:*", "@blocksuite/affine-block-surface": "workspace:*", "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-shared": "workspace:*", "@blocksuite/block-std": "workspace:*", + "@blocksuite/data-view": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.2.1", "@blocksuite/inline": "workspace:*", "@blocksuite/store": "workspace:*", - "@floating-ui/dom": "^1.6.13", "@lit/context": "^1.1.2", "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.12", diff --git a/blocksuite/affine/block-note/src/configs/toolbar.ts b/blocksuite/affine/block-note/src/configs/toolbar.ts new file mode 100644 index 0000000000..2107b6a0f7 --- /dev/null +++ b/blocksuite/affine/block-note/src/configs/toolbar.ts @@ -0,0 +1,374 @@ +import { + convertToDatabase, + DATABASE_CONVERT_WHITE_LIST, +} from '@blocksuite/affine-block-database'; +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '@blocksuite/affine-block-embed'; +import { + deleteTextCommand, + formatBlockCommand, + formatNativeCommand, + formatTextCommand, + isFormatSupported, + textConversionConfigs, + textFormatConfigs, +} from '@blocksuite/affine-components/rich-text'; +import { toast } from '@blocksuite/affine-components/toast'; +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 } from '@blocksuite/affine-shared/services'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { type BlockComponent, BlockSelection } from '@blocksuite/block-std'; +import { tableViewMeta } from '@blocksuite/data-view/view-presets'; +import { + ArrowDownSmallIcon, + CopyIcon, + DatabaseTableViewIcon, + DeleteIcon, + DuplicateIcon, + LinkedPageIcon, +} from '@blocksuite/icons/lit'; +import { toDraftModel } from '@blocksuite/store'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { updateBlockType } from '../commands'; + +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 && type === model.type : true) + ) ?? textConversionConfigs[0]; + const update = (flavour: string, type?: string) => { + chain + .pipe(updateBlockType, { + flavour, + ...(type && { props: { type } }), + }) + .run(); + }; + + return { + content: html` + + ${conversion.icon} ${ArrowDownSmallIcon()} + + `} + > +
+ ${repeat( + textConversionConfigs.filter(c => c.flavour !== 'affine:divider'), + item => item.name, + ({ flavour, type, name, icon }) => html` + update(flavour, type)} + > + ${icon}${name} + + ` + )} +
+
+ `, + }; + }, +} as const satisfies ToolbarActionGenerator; + +const inlineTextActionGroup = { + id: 'b.inline-text', + when: ({ chain }) => isFormatSupported(chain).run()[0], + actions: textFormatConfigs.map( + ({ id, name, action, activeWhen, icon }, score) => { + return { + id, + icon, + score, + tooltip: name, + run: ({ host }) => action(host), + active: ({ host }) => activeWhen(host), + }; + } + ), +} as const satisfies ToolbarActionGroup; + +const highlightActionGroup = { + id: 'c.highlight', + when: ({ chain }) => isFormatSupported(chain).run()[0], + content({ chain }) { + const updateHighlight = (styles: AffineTextAttributes) => { + 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` + + `; + }, +} as const satisfies ToolbarAction; + +export const turnIntoDatabase = { + id: 'd.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; + +export const turnIntoLinkedDoc = { + id: 'e.convert-to-linked-doc', + tooltip: 'Create Linked Doc', + icon: LinkedPageIcon(), + when({ chain }) { + 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, store); + + 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, + inlineTextActionGroup, + highlightActionGroup, + turnIntoDatabase, + turnIntoLinkedDoc, + { + 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(draftSelectedModelsCommand) + .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; diff --git a/blocksuite/affine/block-note/src/note-spec.ts b/blocksuite/affine/block-note/src/note-spec.ts index a51d9e54ce..2ebf489f4c 100644 --- a/blocksuite/affine/block-note/src/note-spec.ts +++ b/blocksuite/affine/block-note/src/note-spec.ts @@ -1,4 +1,10 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { NoteBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; @@ -6,18 +12,29 @@ import { DocNoteBlockAdapterExtensions, EdgelessNoteBlockAdapterExtensions, } from './adapters/index.js'; +import { builtinToolbarConfig } from './configs/toolbar.js'; import { NoteBlockService } from './note-service.js'; +const flavour = NoteBlockSchema.model.flavour; + export const NoteBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:note'), + FlavourExtension(flavour), NoteBlockService, - BlockViewExtension('affine:note', literal`affine-note`), + BlockViewExtension(flavour, literal`affine-note`), DocNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); export const EdgelessNoteBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:note'), + FlavourExtension(flavour), NoteBlockService, - BlockViewExtension('affine:note', literal`affine-edgeless-note`), + BlockViewExtension(flavour, literal`affine-edgeless-note`), EdgelessNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/block-root/package.json b/blocksuite/affine/block-root/package.json index 5563e4b555..2919dd7374 100644 --- a/blocksuite/affine/block-root/package.json +++ b/blocksuite/affine/block-root/package.json @@ -36,6 +36,7 @@ "@blocksuite/affine-widget-frame-title": "workspace:*", "@blocksuite/affine-widget-remote-selection": "workspace:*", "@blocksuite/affine-widget-scroll-anchoring": "workspace:*", + "@blocksuite/affine-widget-toolbar": "workspace:*", "@blocksuite/block-std": "workspace:*", "@blocksuite/data-view": "workspace:*", "@blocksuite/global": "workspace:*", diff --git a/blocksuite/affine/block-root/src/common-specs/index.ts b/blocksuite/affine/block-root/src/common-specs/index.ts index 1cc89358d7..b01a54736e 100644 --- a/blocksuite/affine/block-root/src/common-specs/index.ts +++ b/blocksuite/affine/block-root/src/common-specs/index.ts @@ -5,17 +5,17 @@ import { EmbedOptionService, PageViewportServiceExtension, ThemeService, + ToolbarRegistryExtension, } from '@blocksuite/affine-shared/services'; import { dragHandleWidget } from '@blocksuite/affine-widget-drag-handle'; import { docRemoteSelectionWidget } from '@blocksuite/affine-widget-remote-selection'; import { scrollAnchoringWidget } from '@blocksuite/affine-widget-scroll-anchoring'; +import { toolbarWidget } from '@blocksuite/affine-widget-toolbar'; import { FlavourExtension } from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { RootBlockAdapterExtensions } from '../adapters/extension'; import { - embedCardToolbarWidget, - formatBarWidget, innerModalWidget, linkedDocWidget, modalWidget, @@ -31,6 +31,7 @@ export const CommonSpecs: ExtensionType[] = [ PageViewportServiceExtension, DNDAPIExtension, FileDropExtension, + ToolbarRegistryExtension, ...RootBlockAdapterExtensions, modalWidget, @@ -38,11 +39,10 @@ export const CommonSpecs: ExtensionType[] = [ slashMenuWidget, linkedDocWidget, dragHandleWidget, - embedCardToolbarWidget, - formatBarWidget, docRemoteSelectionWidget, viewportOverlayWidget, scrollAnchoringWidget, + toolbarWidget, ]; export * from './widgets'; diff --git a/blocksuite/affine/block-root/src/common-specs/widgets.ts b/blocksuite/affine/block-root/src/common-specs/widgets.ts index a247391d67..c72600a173 100644 --- a/blocksuite/affine/block-root/src/common-specs/widgets.ts +++ b/blocksuite/affine/block-root/src/common-specs/widgets.ts @@ -1,8 +1,6 @@ import { WidgetViewExtension } from '@blocksuite/block-std'; import { literal, unsafeStatic } from 'lit/static-html.js'; -import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js'; -import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js'; import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js'; import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/config.js'; import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js'; @@ -29,16 +27,6 @@ export const linkedDocWidget = WidgetViewExtension( AFFINE_LINKED_DOC_WIDGET, literal`${unsafeStatic(AFFINE_LINKED_DOC_WIDGET)}` ); -export const embedCardToolbarWidget = WidgetViewExtension( - 'affine:page', - AFFINE_EMBED_CARD_TOOLBAR_WIDGET, - literal`${unsafeStatic(AFFINE_EMBED_CARD_TOOLBAR_WIDGET)}` -); -export const formatBarWidget = WidgetViewExtension( - 'affine:page', - AFFINE_FORMAT_BAR_WIDGET, - literal`${unsafeStatic(AFFINE_FORMAT_BAR_WIDGET)}` -); export const viewportOverlayWidget = WidgetViewExtension( 'affine:page', AFFINE_VIEWPORT_OVERLAY_WIDGET, diff --git a/blocksuite/affine/block-root/src/effects.ts b/blocksuite/affine/block-root/src/effects.ts index 96c058bae4..f0d004b2fe 100644 --- a/blocksuite/affine/block-root/src/effects.ts +++ b/blocksuite/affine/block-root/src/effects.ts @@ -75,15 +75,11 @@ import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/te import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js'; import { EdgelessTextMenu } from './edgeless/components/toolbar/text/text-menu.js'; import { - AFFINE_EMBED_CARD_TOOLBAR_WIDGET, - AFFINE_FORMAT_BAR_WIDGET, - AffineFormatBarWidget, AffineImageToolbarWidget, AffineModalWidget, EDGELESS_TOOLBAR_WIDGET, EdgelessRootBlockComponent, EdgelessRootPreviewBlockComponent, - EmbedCardToolbar, FramePreview, PageRootBlockComponent, PreviewRootBlockComponent, @@ -153,7 +149,6 @@ function registerRootComponents() { } function registerWidgets() { - customElements.define(AFFINE_EMBED_CARD_TOOLBAR_WIDGET, EmbedCardToolbar); customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget); customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget); customElements.define( @@ -171,7 +166,6 @@ function registerWidgets() { AffineEdgelessZoomToolbarWidget ); customElements.define(AFFINE_SURFACE_REF_TOOLBAR, AffineSurfaceRefToolbar); - customElements.define(AFFINE_FORMAT_BAR_WIDGET, AffineFormatBarWidget); } function registerEdgelessToolbarComponents() { diff --git a/blocksuite/affine/block-root/src/types.ts b/blocksuite/affine/block-root/src/types.ts index d7a89c9ba1..409b693d73 100644 --- a/blocksuite/affine/block-root/src/types.ts +++ b/blocksuite/affine/block-root/src/types.ts @@ -9,8 +9,6 @@ import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block. import type { PageRootBlockComponent } from './page/page-root-block.js'; import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js'; import type { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from './widgets/element-toolbar/index.js'; -import type { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from './widgets/embed-card-toolbar/embed-card-toolbar.js'; -import type { AFFINE_FORMAT_BAR_WIDGET } from './widgets/format-bar/format-bar.js'; import type { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from './widgets/index.js'; import type { AFFINE_INNER_MODAL_WIDGET } from './widgets/inner-modal/inner-modal.js'; import type { AFFINE_LINKED_DOC_WIDGET } from './widgets/linked-doc/config.js'; @@ -27,8 +25,6 @@ export type PageRootBlockWidgetName = | typeof AFFINE_LINKED_DOC_WIDGET | typeof AFFINE_PAGE_DRAGGING_AREA_WIDGET | typeof AFFINE_DRAG_HANDLE_WIDGET - | typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET - | typeof AFFINE_FORMAT_BAR_WIDGET | typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET | typeof AFFINE_VIEWPORT_OVERLAY_WIDGET; @@ -38,8 +34,6 @@ export type EdgelessRootBlockWidgetName = | typeof AFFINE_SLASH_MENU_WIDGET | typeof AFFINE_LINKED_DOC_WIDGET | typeof AFFINE_DRAG_HANDLE_WIDGET - | typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET - | typeof AFFINE_FORMAT_BAR_WIDGET | typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET | typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET | typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET diff --git a/blocksuite/affine/block-root/src/utils/types.ts b/blocksuite/affine/block-root/src/utils/types.ts index 4f2c1d742e..91ff028e42 100644 --- a/blocksuite/affine/block-root/src/utils/types.ts +++ b/blocksuite/affine/block-root/src/utils/types.ts @@ -7,25 +7,12 @@ import { EmbedLoomBlockComponent, EmbedSyncedDocBlockComponent, EmbedYoutubeBlockComponent, + type LinkableEmbedBlockComponent, } from '@blocksuite/affine-block-embed'; import type { BlockComponent } from '@blocksuite/block-std'; -export type ExternalEmbedBlockComponent = - | BookmarkBlockComponent - | EmbedFigmaBlockComponent - | EmbedGithubBlockComponent - | EmbedLoomBlockComponent - | EmbedYoutubeBlockComponent; - -export type InternalEmbedBlockComponent = - | EmbedLinkedDocBlockComponent - | EmbedSyncedDocBlockComponent; - -export type LinkableEmbedBlockComponent = - | ExternalEmbedBlockComponent - | InternalEmbedBlockComponent; - export type BuiltInEmbedBlockComponent = + | BookmarkBlockComponent | LinkableEmbedBlockComponent | EmbedHtmlBlockComponent; diff --git a/blocksuite/affine/block-root/src/widgets/element-toolbar/change-attachment-button.ts b/blocksuite/affine/block-root/src/widgets/element-toolbar/change-attachment-button.ts index 68c093ec7e..747e55dc79 100644 --- a/blocksuite/affine/block-root/src/widgets/element-toolbar/change-attachment-button.ts +++ b/blocksuite/affine/block-root/src/widgets/element-toolbar/change-attachment-button.ts @@ -1,6 +1,6 @@ import { type AttachmentBlockComponent, - attachmentViewToggleMenu, + attachmentViewDropdownMenu, } from '@blocksuite/affine-block-attachment'; import { getEmbedCardIcons } from '@blocksuite/affine-block-embed'; import { @@ -17,7 +17,10 @@ import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, } from '@blocksuite/affine-shared/consts'; -import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + ThemeProvider, + ToolbarContext, +} from '@blocksuite/affine-shared/services'; import { Bound } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/utils'; import type { TemplateResult } from 'lit'; @@ -79,17 +82,6 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) { return this.edgeless.std; } - get viewToggleMenu() { - const block = this._block; - const model = this.model; - if (!block || !model) return nothing; - - return attachmentViewToggleMenu({ - block, - callback: () => this.requestUpdate(), - }); - } - override render() { return join( [ @@ -115,7 +107,10 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) { `, - this.viewToggleMenu, + + // TODO(@fundon): should remove it when refactoring the element toolbar + attachmentViewDropdownMenu(new ToolbarContext(this.std)), + html` `, - ].filter(button => button !== nothing && button), + ].filter(button => button !== null), renderToolbarSeparator ); } diff --git a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/config.ts b/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/config.ts deleted file mode 100644 index 942399e541..0000000000 --- a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/config.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - CopyIcon, - DeleteIcon, - DuplicateIcon, - RefreshIcon, -} from '@blocksuite/affine-components/icons'; -import { toast } from '@blocksuite/affine-components/toast'; -import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; -import { getBlockProps } from '@blocksuite/affine-shared/utils'; -import { Slice } from '@blocksuite/store'; - -import { - isAttachmentBlock, - isBookmarkBlock, - isEmbeddedLinkBlock, - isImageBlock, -} from '../../edgeless/utils/query.js'; -import type { EmbedCardToolbarContext } from './context.js'; - -export const BUILT_IN_GROUPS: MenuItemGroup[] = [ - { - type: 'clipboard', - items: [ - { - type: 'copy', - label: 'Copy', - icon: CopyIcon, - disabled: ({ doc }) => doc.readonly, - action: async ({ host, doc, std, blockComponent, close }) => { - const slice = Slice.fromModels(doc, [blockComponent.model]); - await std.clipboard.copySlice(slice); - toast(host, 'Copied link to clipboard'); - close(); - }, - }, - { - type: 'duplicate', - label: 'Duplicate', - icon: DuplicateIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - const model = blockComponent.model; - const blockProps = getBlockProps(model); - const { - width: _width, - height: _height, - xywh: _xywh, - rotate: _rotate, - zIndex: _zIndex, - ...duplicateProps - } = blockProps; - - const parent = doc.getParent(model); - const index = parent?.children.indexOf(model); - doc.addBlock(model.flavour, duplicateProps, parent, index); - close(); - }, - }, - { - type: 'reload', - label: 'Reload', - icon: RefreshIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, close }) => { - blockComponent?.refreshData(); - close(); - }, - when: ({ blockComponent }) => { - const model = blockComponent.model; - - return ( - !!model && - (isImageBlock(model) || - isBookmarkBlock(model) || - isAttachmentBlock(model) || - isEmbeddedLinkBlock(model)) - ); - }, - }, - ], - }, - { - type: 'delete', - items: [ - { - type: 'delete', - label: 'Delete', - icon: DeleteIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - doc.deleteBlock(blockComponent.model); - close(); - }, - }, - ], - }, -]; diff --git a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/context.ts b/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/context.ts deleted file mode 100644 index e4cb7f791a..0000000000 --- a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MenuContext } from '@blocksuite/affine-components/toolbar'; - -import type { BuiltInEmbedBlockComponent } from '../../utils'; - -export class EmbedCardToolbarContext extends MenuContext { - override close = () => { - this.abortController.abort(); - }; - - get doc() { - return this.blockComponent.doc; - } - - get host() { - return this.blockComponent.host; - } - - get selectedBlockModels() { - if (this.blockComponent.model) return [this.blockComponent.model]; - return []; - } - - get std() { - return this.host.std; - } - - constructor( - public blockComponent: BuiltInEmbedBlockComponent, - public abortController: AbortController - ) { - super(); - } - - isEmpty() { - return false; - } - - isMultiple() { - return false; - } - - isSingle() { - return true; - } -} diff --git a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/embed-card-toolbar.ts b/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/embed-card-toolbar.ts deleted file mode 100644 index 7b25a7dcb1..0000000000 --- a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/embed-card-toolbar.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { - EmbedLinkedDocBlockComponent, - EmbedSyncedDocBlockComponent, - getDocContentWithMaxLength, - getEmbedCardIcons, -} from '@blocksuite/affine-block-embed'; -import { - toggleEmbedCardCaptionEditModal, - toggleEmbedCardEditModal, -} from '@blocksuite/affine-components/embed-card-modal'; -import { - CaptionIcon, - CopyIcon, - EditIcon, - OpenIcon, - PaletteIcon, -} from '@blocksuite/affine-components/icons'; -import { - notifyLinkedDocClearedAliases, - notifyLinkedDocSwitchedToCard, - notifyLinkedDocSwitchedToEmbed, -} from '@blocksuite/affine-components/notification'; -import { isPeekable, peek } from '@blocksuite/affine-components/peek'; -import { toast } from '@blocksuite/affine-components/toast'; -import { - cloneGroups, - getMoreMenuConfig, - type MenuItem, - type MenuItemGroup, - renderGroups, - renderToolbarSeparator, -} from '@blocksuite/affine-components/toolbar'; -import { - type AliasInfo, - type BookmarkBlockModel, - BookmarkStyles, - type BuiltInEmbedModel, - type EmbedCardStyle, - type EmbedGithubModel, - type EmbedLinkedDocModel, - isInternalEmbedModel, - type RootBlockModel, -} from '@blocksuite/affine-model'; -import { - EmbedOptionProvider, - type EmbedOptions, - GenerateDocUrlProvider, - type GenerateDocUrlService, - type LinkEventType, - OpenDocExtensionIdentifier, - type TelemetryEvent, - TelemetryProvider, - ThemeProvider, -} from '@blocksuite/affine-shared/services'; -import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils'; -import { - BlockSelection, - type BlockStdScope, - TextSelection, - WidgetComponent, -} from '@blocksuite/block-std'; -import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit'; -import { type BlockModel, Text } from '@blocksuite/store'; -import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom'; -import { html, nothing, type TemplateResult } from 'lit'; -import { query, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { join } from 'lit/directives/join.js'; -import { repeat } from 'lit/directives/repeat.js'; -import * as Y from 'yjs'; - -import { - isBookmarkBlock, - isEmbedGithubBlock, - isEmbedHtmlBlock, - isEmbedLinkedDocBlock, - isEmbedSyncedDocBlock, -} from '../../edgeless/utils/query.js'; -import type { RootBlockComponent } from '../../types.js'; -import { - type BuiltInEmbedBlockComponent, - isEmbedCardBlockComponent, -} from '../../utils/types'; -import { BUILT_IN_GROUPS } from './config.js'; -import { EmbedCardToolbarContext } from './context.js'; -import { embedCardToolbarStyle } from './styles.js'; - -export const AFFINE_EMBED_CARD_TOOLBAR_WIDGET = 'affine-embed-card-toolbar'; - -export class EmbedCardToolbar extends WidgetComponent< - RootBlockModel, - RootBlockComponent -> { - static override styles = embedCardToolbarStyle; - - private _abortController = new AbortController(); - - private readonly _copyUrl = () => { - const model = this.focusModel; - if (!model) return; - - let url!: ReturnType; - const isInternal = isInternalEmbedModel(model); - - if ('url' in model) { - url = model.url; - } else if (isInternal) { - url = this.std - .getOptional(GenerateDocUrlProvider) - ?.generateDocUrl(model.pageId, model.params); - } - - if (!url) return; - - navigator.clipboard.writeText(url).catch(console.error); - toast(this.std.host, 'Copied link to clipboard'); - - track(this.std, model, this._viewType, 'CopiedLink', { - control: 'copy link', - }); - }; - - private _embedOptions: EmbedOptions | null = null; - - private readonly _openEditPopup = (e: MouseEvent) => { - e.stopPropagation(); - - const model = this.focusModel; - if (!model || isEmbedHtmlBlock(model)) return; - - const originalDocInfo = this._originalDocInfo; - - this._hide(); - - toggleEmbedCardEditModal( - this.host, - model, - this._viewType, - originalDocInfo, - (std, component) => { - if ( - isEmbedLinkedDocBlock(model) && - component instanceof EmbedLinkedDocBlockComponent - ) { - component.refreshData(); - - notifyLinkedDocClearedAliases(std); - } - }, - (std, component, props) => { - if ( - isEmbedSyncedDocBlock(model) && - component instanceof EmbedSyncedDocBlockComponent - ) { - component.convertToCard(props); - - notifyLinkedDocSwitchedToCard(std); - } else { - this.model.doc.updateBlock(model, props); - component.requestUpdate(); - } - } - ); - - track(this.std, model, this._viewType, 'OpenedAliasPopup', { - control: 'edit', - }); - }; - - private readonly _resetAbortController = () => { - this._abortController.abort(); - this._abortController = new AbortController(); - }; - - private readonly _showCaption = () => { - const focusBlock = this.focusBlock; - if (!focusBlock) { - return; - } - try { - focusBlock.captionEditor?.show(); - } catch { - toggleEmbedCardCaptionEditModal(focusBlock); - } - this._resetAbortController(); - - const model = this.focusModel; - if (!model) return; - - track(this.std, model, this._viewType, 'OpenedCaptionEditor', { - control: 'add caption', - }); - }; - - private readonly _toggleCardStyleSelector = (e: Event) => { - const opened = (e as CustomEvent).detail; - if (!opened) return; - - const model = this.focusModel; - if (!model) return; - - track(this.std, model, this._viewType, 'OpenedCardStyleSelector', { - control: 'switch card style', - }); - }; - - private readonly _toggleViewSelector = (e: Event) => { - const opened = (e as CustomEvent).detail; - if (!opened) return; - - const model = this.focusModel; - if (!model) return; - - track(this.std, model, this._viewType, 'OpenedViewSelector', { - control: 'switch view', - }); - }; - - private readonly _trackViewSelected = (type: string) => { - const model = this.focusModel; - if (!model) return; - - track(this.std, model, this._viewType, 'SelectedView', { - control: 'selected view', - type: `${type} view`, - }); - }; - - /* - * Caches the more menu items. - * Currently only supports configuring more menu. - */ - moreGroups: MenuItemGroup[] = - cloneGroups(BUILT_IN_GROUPS); - - private get _canConvertToEmbedView() { - if (!this.focusBlock) return false; - - return ( - 'convertToEmbed' in this.focusBlock || - this._embedOptions?.viewType === 'embed' - ); - } - - private get _canShowUrlOptions() { - return this.focusModel && 'url' in this.focusModel && this._isCardView; - } - - private get _embedViewButtonDisabled() { - if (this.doc.readonly) { - return true; - } - return ( - this.focusModel && - this.focusBlock && - isEmbedLinkedDocBlock(this.focusModel) && - (referenceToNode(this.focusModel) || - !!this.focusBlock.closest('affine-embed-synced-doc-block') || - this.focusModel.pageId === this.doc.id) - ); - } - - private get _isCardView() { - return ( - this.focusModel && - (isBookmarkBlock(this.focusModel) || - isEmbedLinkedDocBlock(this.focusModel) || - this._embedOptions?.viewType === 'card') - ); - } - - private get _isEmbedView() { - return ( - this.focusModel && - !isBookmarkBlock(this.focusModel) && - (isEmbedSyncedDocBlock(this.focusModel) || - this._embedOptions?.viewType === 'embed') - ); - } - - get _openButtonDisabled() { - return ( - this.focusModel && - isEmbedLinkedDocBlock(this.focusModel) && - this.focusModel.pageId === this.doc.id - ); - } - - get _originalDocInfo(): AliasInfo | undefined { - const model = this.focusModel; - if (!model) return undefined; - - const doc = isInternalEmbedModel(model) - ? this.std.workspace.getDoc(model.pageId) - : null; - - if (doc) { - const title = doc.meta?.title; - const description = isEmbedLinkedDocBlock(model) - ? getDocContentWithMaxLength(doc) - : undefined; - return { title, description }; - } - - return undefined; - } - - get _originalDocTitle() { - const model = this.focusModel; - if (!model) return undefined; - - const doc = isInternalEmbedModel(model) - ? this.std.workspace.getDoc(model.pageId) - : null; - - return doc?.meta?.title || 'Untitled'; - } - - private get _selection() { - return this.host.selection; - } - - private get _viewType(): 'inline' | 'embed' | 'card' { - if (this._isCardView) { - return 'card'; - } - - if (this._isEmbedView) { - return 'embed'; - } - - return 'inline'; - } - - get focusModel(): BuiltInEmbedModel | undefined { - return this.focusBlock?.model; - } - - private _canShowCardStylePanel( - model: BlockModel - ): model is BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel { - return ( - isBookmarkBlock(model) || - isEmbedGithubBlock(model) || - isEmbedLinkedDocBlock(model) - ); - } - - private _cardStyleSelector() { - const model = this.focusModel; - - if (!model) return nothing; - if (!this._canShowCardStylePanel(model)) return nothing; - - const theme = this.std.get(ThemeProvider).theme; - const { EmbedCardHorizontalIcon, EmbedCardListIcon } = - getEmbedCardIcons(theme); - - const buttons = [ - { - type: 'horizontal', - label: 'Large horizontal style', - icon: EmbedCardHorizontalIcon, - }, - { - type: 'list', - label: 'Small horizontal style', - icon: EmbedCardListIcon, - }, - ] as { - type: EmbedCardStyle; - label: string; - icon: TemplateResult<1>; - }[]; - - return html` - - ${PaletteIcon} - - `} - @toggle=${this._toggleCardStyleSelector} - > -
- ${repeat( - buttons, - button => button.type, - ({ type, label, icon }) => html` - this._setEmbedCardStyle(type)} - > - ${icon} - ${label} - - ` - )} -
- - `; - } - - private _convertToCardView() { - if (this._isCardView) { - return; - } - if (!this.focusBlock) { - return; - } - - if ('convertToCard' in this.focusBlock) { - this.focusBlock.convertToCard(); - return; - } - - if (!this.focusModel || !('url' in this.focusModel)) { - return; - } - - const targetModel = this.focusModel; - const { doc, url, style, caption } = targetModel; - - let targetFlavour = 'affine:bookmark', - targetStyle = style; - - if (this._embedOptions && this._embedOptions.viewType === 'card') { - const { flavour, styles } = this._embedOptions; - targetFlavour = flavour; - targetStyle = styles.includes(style) ? style : styles[0]; - } else { - targetStyle = BookmarkStyles.includes(style) - ? style - : BookmarkStyles.filter( - style => style !== 'vertical' && style !== 'cube' - )[0]; - } - - const parent = doc.getParent(targetModel); - if (!parent) return; - const index = parent.children.indexOf(targetModel); - - doc.addBlock( - targetFlavour as never, - { url, style: targetStyle, caption }, - parent, - index - ); - this.std.selection.setGroup('note', []); - doc.deleteBlock(targetModel); - } - - private _convertToEmbedView() { - if (this._isEmbedView) { - return; - } - - if (!this.focusBlock) { - return; - } - - if ('convertToEmbed' in this.focusBlock) { - const referenceInfo = this.focusBlock.referenceInfo$.peek(); - - this.focusBlock.convertToEmbed(); - - if (referenceInfo.title || referenceInfo.description) { - notifyLinkedDocSwitchedToEmbed(this.std); - } - - return; - } - - if (!this.focusModel || !('url' in this.focusModel)) { - return; - } - - const targetModel = this.focusModel; - const { doc, url, style, caption } = targetModel; - - if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { - return; - } - const { flavour, styles } = this._embedOptions; - - const targetStyle = styles.includes(style) - ? style - : styles.filter(style => style !== 'vertical' && style !== 'cube')[0]; - - const parent = doc.getParent(targetModel); - if (!parent) return; - const index = parent.children.indexOf(targetModel); - - doc.addBlock( - flavour as never, - { url, style: targetStyle, caption }, - parent, - index - ); - - this.std.selection.setGroup('note', []); - doc.deleteBlock(targetModel); - } - - private _hide() { - this._resetAbortController(); - this.focusBlock = null; - this.hide = true; - } - - private _moreActions() { - if (!this.focusBlock) return nothing; - const context = new EmbedCardToolbarContext( - this.focusBlock, - this._abortController - ); - return renderGroups(this.moreGroups, context); - } - - private _openMenuButton() { - const openDocConfig = this.std.get(OpenDocExtensionIdentifier); - const element = this.focusBlock; - const buttons: MenuItem[] = openDocConfig.items - .map(item => { - if ( - item.type === 'open-in-center-peek' && - element && - !isPeekable(element) - ) { - return null; - } - - if ( - !( - this.focusModel && - (isEmbedLinkedDocBlock(this.focusModel) || - isEmbedSyncedDocBlock(this.focusModel)) - ) - ) { - return null; - } - - return { - label: item.label, - type: item.type, - icon: item.icon, - action: () => { - if (item.type === 'open-in-center-peek') { - element && peek(element); - } else { - this.focusBlock?.open({ openMode: item.type }); - } - }, - }; - }) - .filter(item => item !== null); - - if (buttons.length === 0) { - return nothing; - } - - return html` - - ${OpenIcon}${ArrowDownSmallIcon({ width: '16px', height: '16px' })} - - `} - > -
- ${repeat( - buttons, - button => button.label, - ({ label, icon, action, disabled }) => html` - - ${icon}${label} - - ` - )} -
-
- `; - } - - private _setEmbedCardStyle(style: EmbedCardStyle) { - const model = this.focusModel; - if (!model) return; - - model.doc.updateBlock(model, { style }); - this.requestUpdate(); - this._abortController.abort(); - - track(this.std, model, this._viewType, 'SelectedCardStyle', { - control: 'select card style', - type: style, - }); - } - - private _show() { - if (!this.focusBlock) { - return; - } - this.hide = false; - this._abortController.signal.addEventListener( - 'abort', - autoUpdate(this.focusBlock, this, () => { - if (!this.focusBlock) { - return; - } - computePosition(this.focusBlock, this, { - placement: 'top-start', - middleware: [flip(), offset(8)], - }) - .then(({ x, y }) => { - this.style.left = `${x}px`; - this.style.top = `${y}px`; - }) - .catch(console.error); - }) - ); - } - - private _turnIntoInlineView() { - if (this.focusBlock && 'covertToInline' in this.focusBlock) { - this.focusBlock.covertToInline(); - return; - } - - if (!this.focusModel || !('url' in this.focusModel)) { - return; - } - - const targetModel = this.focusModel; - const { doc, title, caption, url } = targetModel; - const parent = doc.getParent(targetModel); - const index = parent?.children.indexOf(targetModel); - - const yText = new Y.Text(); - const insert = title || caption || url; - yText.insert(0, insert); - yText.format(0, insert.length, { link: url }); - const text = new Text(yText); - doc.addBlock( - 'affine:paragraph', - { - text, - }, - parent, - index - ); - - doc.deleteBlock(targetModel); - } - - private _viewSelector() { - const buttons = []; - - buttons.push({ - type: 'inline', - label: 'Inline view', - action: () => this._turnIntoInlineView(), - disabled: this.doc.readonly, - }); - - buttons.push({ - type: 'card', - label: 'Card view', - action: () => this._convertToCardView(), - disabled: this.doc.readonly, - }); - - if (this._canConvertToEmbedView || this._isEmbedView) { - buttons.push({ - type: 'embed', - label: 'Embed view', - action: () => this._convertToEmbedView(), - disabled: this.doc.readonly || this._embedViewButtonDisabled, - }); - } - - return html` - -
- ${this._viewType} - view -
- ${ArrowDownSmallIcon({ width: '16px', height: '16px' })} - - `} - @toggle=${this._toggleViewSelector} - > -
- ${repeat( - buttons, - button => button.type, - ({ type, label, action, disabled }) => html` - { - action(); - this._trackViewSelected(type); - this._hide(); - }} - > - ${label} - - ` - )} -
-
- `; - } - - override connectedCallback() { - super.connectedCallback(); - - this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); - - this.disposables.add( - this._selection.slots.changed.on(() => { - const hasTextSelection = this._selection.find(TextSelection); - if (hasTextSelection) { - this._hide(); - return; - } - - const blockSelections = this._selection.filter(BlockSelection); - if (!blockSelections || blockSelections.length !== 1) { - this._hide(); - return; - } - - const block = this.std.view.getBlock(blockSelections[0].blockId); - if (!block || !isEmbedCardBlockComponent(block)) { - this._hide(); - return; - } - - this.focusBlock = block as BuiltInEmbedBlockComponent; - this._show(); - }) - ); - } - - override render() { - if (this.hide) return nothing; - - const model = this.focusModel; - if (!model) return nothing; - - this._embedOptions = - 'url' in model - ? this.std.get(EmbedOptionProvider).getEmbedBlockOptions(model.url) - : null; - - const hasUrl = this._canShowUrlOptions && 'url' in model; - - const buttons = [ - this._openMenuButton(), - - hasUrl - ? html` - - ${getHostName(model.url)} - - ` - : nothing, - - // internal embed model - isEmbedLinkedDocBlock(model) && model.title - ? html` - - ${this._originalDocTitle} - - ` - : nothing, - - isEmbedHtmlBlock(model) - ? nothing - : html` - - ${CopyIcon} - - - - ${EditIcon} - - `, - - this._viewSelector(), - - this._cardStyleSelector(), - - html` - - ${CaptionIcon} - - `, - - html` - - ${MoreVerticalIcon()} - - `} - > -
- ${this._moreActions()} -
-
- `, - ]; - - return html` - - ${join( - buttons.filter(button => button !== nothing), - renderToolbarSeparator - )} - - `; - } - - @query('.embed-card-toolbar-button.card-style') - accessor cardStyleButton: HTMLElement | null = null; - - @query('.embed-card-toolbar') - accessor embedCardToolbarElement!: HTMLElement; - - @state() - accessor focusBlock: BuiltInEmbedBlockComponent | null = null; - - @state() - accessor hide: boolean = true; - - @query('.embed-card-toolbar-button.more-button') - accessor moreButton: HTMLElement | null = null; -} - -declare global { - interface HTMLElementTagNameMap { - [AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: EmbedCardToolbar; - } -} - -function track( - std: BlockStdScope, - model: BuiltInEmbedModel, - viewType: string, - event: LinkEventType, - props: Partial -) { - std.getOptional(TelemetryProvider)?.track(event, { - segment: 'toolbar', - page: 'doc editor', - module: 'embed card toolbar', - type: `${viewType} view`, - category: isInternalEmbedModel(model) ? 'linked doc' : 'link', - ...props, - }); -} diff --git a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/styles.ts b/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/styles.ts deleted file mode 100644 index c042746bcf..0000000000 --- a/blocksuite/affine/block-root/src/widgets/embed-card-toolbar/styles.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { css } from 'lit'; - -export const embedCardToolbarStyle = css` - :host { - position: absolute; - top: 0; - left: 0; - z-index: var(--affine-z-index-popover); - } - - .affine-link-preview { - display: flex; - justify-content: flex-start; - min-width: 60px; - max-width: 140px; - padding: var(--1, 0px); - border-radius: var(--1, 0px); - opacity: var(--add, 1); - user-select: none; - cursor: pointer; - - color: var(--affine-link-color); - font-feature-settings: - 'clig' off, - 'liga' off; - font-family: var(--affine-font-family); - font-size: var(--affine-font-sm); - font-style: normal; - font-weight: 400; - text-decoration: none; - text-wrap: nowrap; - } - - .affine-link-preview > span { - display: inline-block; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - - text-overflow: ellipsis; - overflow: hidden; - opacity: var(--add, 1); - } - - .card-style-select icon-button.selected { - border: 1px solid var(--affine-brand-color); - } - - editor-icon-button.doc-title .label { - max-width: 110px; - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - user-select: none; - cursor: pointer; - color: var(--affine-link-color); - font-feature-settings: - 'clig' off, - 'liga' off; - font-family: var(--affine-font-family); - font-size: var(--affine-font-sm); - font-style: normal; - font-weight: 400; - text-decoration: none; - text-wrap: nowrap; - } -`; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/components/config-renderer.ts b/blocksuite/affine/block-root/src/widgets/format-bar/components/config-renderer.ts deleted file mode 100644 index cf8fd35753..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/components/config-renderer.ts +++ /dev/null @@ -1,95 +0,0 @@ -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` - { - item.action(formatBar.std.command.chain(), formatBar); - formatBar.requestUpdate(); - }} - > - ${typeof item.icon === 'function' ? item.icon() : item.icon} - - `; - 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) - ); -} diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/consts.ts b/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/consts.ts deleted file mode 100644 index 1d52057c93..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/consts.ts +++ /dev/null @@ -1,42 +0,0 @@ -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, - })), -]; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/highlight-button.ts b/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/highlight-button.ts deleted file mode 100644 index e0f73d14ba..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/components/highlight/highlight-button.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { whenHover } from '@blocksuite/affine-components/hover'; -import { - ArrowDownIcon, - HighLightDuotoneIcon, - TextBackgroundDuotoneIcon, - TextForegroundDuotoneIcon, -} from '@blocksuite/affine-components/icons'; -import { - formatBlockCommand, - formatNativeCommand, - formatTextCommand, -} from '@blocksuite/affine-components/rich-text'; -import { - getBlockSelectionsCommand, - getTextSelectionCommand, -} from '@blocksuite/affine-shared/commands'; -import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; -import type { EditorHost } from '@blocksuite/block-std'; -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 { - Color = 'color', - Background = '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: { - [`${highlightType}`]: color, - }, - }; - host.std.command - .chain() - .try(chain => [ - chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload), - chain.pipe(getBlockSelectionsCommand).pipe(formatBlockCommand, payload), - chain.pipe(formatNativeCommand, payload), - ]) - .run(); -}; - -const HighlightPanel = ( - formatBar: AffineFormatBarWidget, - containerRef?: RefOrCallback -) => { - return html` - -
- -
Color
- ${foregroundConfig.map( - ({ name, color }) => html` - - - ${TextForegroundDuotoneIcon} - - ${name} - - ` - )} - - -
Background
- ${backgroundConfig.map( - ({ name, color }) => html` - - - ${TextBackgroundDuotoneIcon} - - ${name} - - ` - )} -
-
- `; -}; - -export const HighlightButton = (formatBar: AffineFormatBarWidget) => { - const editorHost = formatBar.host; - - const { setFloating, setReference } = whenHover(isHover => { - if (!isHover) { - const panel = - formatBar.shadowRoot?.querySelector('.highlight-panel'); - if (!panel) return; - panel.style.display = 'none'; - return; - } - const button = - formatBar.shadowRoot?.querySelector('.highlight-button'); - const panel = - formatBar.shadowRoot?.querySelector('.highlight-panel'); - if (!button || !panel) { - return; - } - 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` -
- - - ${HighLightDuotoneIcon} - - ${ArrowDownIcon} - - ${highlightPanel} -
- `; -}; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/components/paragraph-button.ts b/blocksuite/affine/block-root/src/widgets/format-bar/components/paragraph-button.ts deleted file mode 100644 index 609e8e333c..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/components/paragraph-button.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { whenHover } from '@blocksuite/affine-components/hover'; -import { ArrowDownIcon } from '@blocksuite/affine-components/icons'; -import { textConversionConfigs } from '@blocksuite/affine-components/rich-text'; -import type { ParagraphBlockModel } from '@blocksuite/affine-model'; -import type { EditorHost } from '@blocksuite/block-std'; -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 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` - - ${typeof item.icon === 'function' ? item.icon() : item.icon} - ${item.name} - - ` - ); - - return html` - -
${renderedConfig}
-
- `; -}; - -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('.paragraph-panel'); - if (!panel) return; - panel.style.display = 'none'; - return; - } - const formatQuickBarElement = formatBar.formatBarElement; - const panel = - formatBar.shadowRoot?.querySelector('.paragraph-panel'); - if (!panel || !formatQuickBarElement) { - return; - } - 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` -
- - ${paragraphIcon} ${ArrowDownIcon} - - ${paragraphPanel} -
- `; -}; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/config.ts b/blocksuite/affine/block-root/src/widgets/format-bar/config.ts deleted file mode 100644 index 6c2606eb0e..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/config.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { - convertToDatabase, - DATABASE_CONVERT_WHITE_LIST, -} from '@blocksuite/affine-block-database'; -import { - convertSelectedBlocksToLinkedDoc, - getTitleFromSelectedModels, - notifyDocCreated, - promptDocTitle, -} from '@blocksuite/affine-block-embed'; -import { - BoldIcon, - BulletedListIcon, - CheckBoxIcon, - CodeIcon, - CopyIcon, - DatabaseTableViewIcon20, - DeleteIcon, - DuplicateIcon, - Heading1Icon, - Heading2Icon, - Heading3Icon, - Heading4Icon, - Heading5Icon, - Heading6Icon, - ItalicIcon, - LinkedDocIcon, - LinkIcon, - NumberedListIcon, - QuoteIcon, - StrikethroughIcon, - TextIcon, - UnderlineIcon, -} from '@blocksuite/affine-components/icons'; -import { - deleteTextCommand, - toggleBold, - toggleCode, - toggleItalic, - toggleLink, - toggleStrike, - toggleUnderline, -} from '@blocksuite/affine-components/rich-text'; -import { toast } from '@blocksuite/affine-components/toast'; -import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; -import { renderGroups } from '@blocksuite/affine-components/toolbar'; -import { - copySelectedModelsCommand, - deleteSelectedModelsCommand, - draftSelectedModelsCommand, - getBlockIndexCommand, - getBlockSelectionsCommand, - getImageSelectionsCommand, - getSelectedBlocksCommand, - getSelectedModelsCommand, - getTextSelectionCommand, -} from '@blocksuite/affine-shared/commands'; -import { TelemetryProvider } from '@blocksuite/affine-shared/services'; -import type { - BlockComponent, - Chain, - InitCommandCtx, -} from '@blocksuite/block-std'; -import { tableViewMeta } from '@blocksuite/data-view/view-presets'; -import { MoreVerticalIcon } from '@blocksuite/icons/lit'; -import { Slice, toDraftModel } from '@blocksuite/store'; -import { html, type TemplateResult } from 'lit'; - -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, - formatBar: AffineFormatBarWidget - ) => void; - icon: TemplateResult | (() => HTMLElement); - isActive: ( - chain: Chain, - formatBar: AffineFormatBarWidget - ) => boolean; - showWhen: ( - chain: Chain, - formatBar: AffineFormatBarWidget - ) => boolean; -}; -export type ParagraphActionConfigItem = { - id: string; - type: 'paragraph-action'; - name: string; - action: ( - chain: Chain, - 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.pipe(toggleBold).run(), - icon: BoldIcon, - }) - .addTextStyleToggle({ - key: 'italic', - action: chain => chain.pipe(toggleItalic).run(), - icon: ItalicIcon, - }) - .addTextStyleToggle({ - key: 'underline', - action: chain => chain.pipe(toggleUnderline).run(), - icon: UnderlineIcon, - }) - .addTextStyleToggle({ - key: 'strike', - action: chain => chain.pipe(toggleStrike).run(), - icon: StrikethroughIcon, - }) - .addTextStyleToggle({ - key: 'code', - action: chain => chain.pipe(toggleCode).run(), - icon: CodeIcon, - }) - .addTextStyleToggle({ - key: 'link', - action: chain => chain.pipe(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: { 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 [result] = chain - .pipe(getTextSelectionCommand) - .pipe(getSelectedBlocksCommand, { - types: ['text'], - }) - .pipe(middleware(1)) - .run(); - - if (result) return true; - - [result] = chain - .tryAll(chain => [ - chain.pipe(getBlockSelectionsCommand), - chain.pipe(getImageSelectionsCommand), - ]) - .pipe(getSelectedBlocksCommand, { - types: ['block', 'image'], - }) - .pipe(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 - .pipe(getSelectedModelsCommand, { - types: ['block', 'text'], - mode: 'flat', - }) - .pipe(draftSelectedModelsCommand) - .run(); - const { draftedModels, selectedModels, std } = ctx; - if (!selectedModels?.length || !draftedModels) return; - - const host = formatBar.host; - host.selection.clear(); - - const doc = host.doc; - const autofill = getTitleFromSelectedModels( - selectedModels.map(toDraftModel) - ); - promptDocTitle(std, autofill) - .then(async title => { - if (title === null) return; - await convertSelectedBlocksToLinkedDoc( - std, - doc, - draftedModels, - title - ); - notifyDocCreated(std, 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', - }); - }) - .catch(console.error); - }, - showWhen: chain => { - const [_, ctx] = chain - .pipe(getSelectedModelsCommand, { - 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[] = [ - { - type: 'clipboard', - items: [ - { - type: 'copy', - label: 'Copy', - icon: CopyIcon, - disabled: c => c.doc.readonly, - action: c => { - c.std.command - .chain() - .pipe(getSelectedModelsCommand) - .with({ - onCopy: () => { - toast(c.host, 'Copied to clipboard'); - }, - }) - .pipe(draftSelectedModelsCommand) - .pipe(copySelectedModelsCommand) - .run(); - }, - }, - { - type: 'duplicate', - label: 'Duplicate', - icon: DuplicateIcon, - disabled: c => c.doc.readonly, - action: c => { - c.doc.captureSync(); - c.std.command - .chain() - .try<{ currentSelectionPath: string }>(cmd => [ - cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => { - const textSelection = ctx.currentTextSelection; - if (!textSelection) { - return; - } - const end = textSelection.to ?? textSelection.from; - next({ currentSelectionPath: end.blockId }); - }), - cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => { - const currentBlockSelections = ctx.currentBlockSelections; - if (!currentBlockSelections) { - return; - } - const blockSelection = currentBlockSelections.at(-1); - if (!blockSelection) { - return; - } - next({ currentSelectionPath: blockSelection.blockId }); - }), - ]) - .pipe(getBlockIndexCommand) - .pipe(getSelectedModelsCommand) - .pipe(draftSelectedModelsCommand) - .pipe((ctx, next) => { - ctx.draftedModels - .then(models => { - const slice = Slice.fromModels(ctx.std.store, models); - return ctx.std.clipboard.duplicateSlice( - slice, - ctx.std.store, - 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() - .pipe(getTextSelectionCommand) - .pipe(deleteTextCommand) - .run(); - - if (result) { - return; - } - - // remove blocks - c.std.command - .chain() - .tryAll(chain => [ - chain.pipe(getBlockSelectionsCommand), - chain.pipe(getImageSelectionsCommand), - ]) - .pipe(getSelectedModelsCommand) - .pipe(deleteSelectedModelsCommand) - .run(); - - c.toolbar.reset(); - }, - }, - ], - }, -]; - -export function toolbarMoreButton(toolbar: AffineFormatBarWidget) { - const richText = getRichText(); - if (richText?.dataset.disableAskAi !== undefined) return null; - const context = new FormatBarContext(toolbar); - const actions = renderGroups(toolbar.moreGroups, context); - - return html` - - ${MoreVerticalIcon()} - - `}" - > -
${actions}
-
- `; -} -const getRichText = () => { - const selection = getSelection(); - if (!selection) return null; - if (selection.rangeCount === 0) return null; - const range = selection.getRangeAt(0); - const commonAncestorContainer = - range.commonAncestorContainer instanceof Element - ? range.commonAncestorContainer - : range.commonAncestorContainer.parentElement; - if (!commonAncestorContainer) return null; - return commonAncestorContainer.closest('rich-text'); -}; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/context.ts b/blocksuite/affine/block-root/src/widgets/format-bar/context.ts deleted file mode 100644 index f58ceded45..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/context.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { MenuContext } from '@blocksuite/affine-components/toolbar'; -import { - getBlockSelectionsCommand, - getImageSelectionsCommand, - getSelectedModelsCommand, - getTextSelectionCommand, -} from '@blocksuite/affine-shared/commands'; - -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.pipe(getTextSelectionCommand), - chain.pipe(getBlockSelectionsCommand), - chain.pipe(getImageSelectionsCommand), - ]) - .pipe(getSelectedModelsCommand, { - 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; - } -} diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/format-bar.ts b/blocksuite/affine/block-root/src/widgets/format-bar/format-bar.ts deleted file mode 100644 index d40aaf8be2..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/format-bar.ts +++ /dev/null @@ -1,614 +0,0 @@ -import { updateBlockType } from '@blocksuite/affine-block-note'; -import { HoverController } from '@blocksuite/affine-components/hover'; -import { - isFormatSupported, - isTextStyleActive, - type RichText, -} from '@blocksuite/affine-components/rich-text'; -import { - cloneGroups, - getMoreMenuConfig, - type MenuItemGroup, -} from '@blocksuite/affine-components/toolbar'; -import { - CodeBlockModel, - ImageBlockModel, - ListBlockModel, - ParagraphBlockModel, -} from '@blocksuite/affine-model'; -import { - getSelectedBlocksCommand, - getTextSelectionCommand, -} from '@blocksuite/affine-shared/commands'; -import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; -import { matchModels } from '@blocksuite/affine-shared/utils'; -import { - type BlockComponent, - BlockSelection, - CursorSelection, - TextSelection, - WidgetComponent, -} from '@blocksuite/block-std'; -import { DisposableGroup, nextTick } from '@blocksuite/global/utils'; -import type { BaseSelection } from '@blocksuite/store'; -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 { 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[] = 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(TextSelection); - const blockSelections = - rootComponent.selection.filter(BlockSelection); - - // Should not re-render format bar when only cursor selection changed in edgeless - const cursorSelection = rootComponent.selection.find(CursorSelection); - 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() - .pipe(getTextSelectionCommand) - .pipe(getSelectedBlocksCommand, { - types: ['text'], - }) - .pipe(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 reset = () => { - this.reset(); - this.requestUpdate(); - }; - const range = this.nativeRange; - if (!range) return; - const container = - range.commonAncestorContainer instanceof Element - ? range.commonAncestorContainer - : range.commonAncestorContainer.parentElement; - if (!container) return; - const notBlockText = container.closest('rich-text')?.dataset.notBlockText; - if (notBlockText == null) return; - if (range.collapsed) return reset(); - this._displayType = 'native'; - this.requestUpdate(); - }); - } - - private _listenFloatingElement() { - const formatQuickBarElement = this.formatBarElement; - if (!formatQuickBarElement) { - return; - } - - const listenFloatingElement = ( - getElement: () => ReferenceElement | void - ) => { - const initialElement = getElement(); - if (!initialElement) { - return; - } - - if (!this._floatDisposables) { - return; - } - - 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.readonly; - 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 ( - !matchModels(selectedBlock.model, [ - ParagraphBlockModel, - ListBlockModel, - CodeBlockModel, - ImageBlockModel, - ]) - ) { - 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('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: string; - 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 - .pipe(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) { - this.configItems.push({ ...config, type: 'inline-action' }); - return this; - } - - addParagraphAction(config: Omit) { - 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.pipe(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; - if (!rootComponent) { - return; - } - 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); - const moreButton = toolbarMoreButton(this); - return html` - - ${items} - ${moreButton - ? html` - - ${moreButton} - ` - : nothing} - - `; - } - - reset() { - this._displayType = 'none'; - this._selectedBlocks = []; - } - - override updated() { - if (this._floatDisposables) { - this._floatDisposables.dispose(); - this._floatDisposables = null; - } - - if (!this._shouldDisplay()) { - 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; - } -} diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/index.ts b/blocksuite/affine/block-root/src/widgets/format-bar/index.ts deleted file mode 100644 index efe6a4df4e..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './config.js'; -export { AffineFormatBarWidget } from './format-bar.js'; diff --git a/blocksuite/affine/block-root/src/widgets/format-bar/styles.ts b/blocksuite/affine/block-root/src/widgets/format-bar/styles.ts deleted file mode 100644 index 93fdeb237e..0000000000 --- a/blocksuite/affine/block-root/src/widgets/format-bar/styles.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { scrollbarStyle } from '@blocksuite/affine-shared/styles'; -import { css } from 'lit'; - -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} -`; diff --git a/blocksuite/affine/block-root/src/widgets/index.ts b/blocksuite/affine/block-root/src/widgets/index.ts index fee243af60..97f27644bf 100644 --- a/blocksuite/affine/block-root/src/widgets/index.ts +++ b/blocksuite/affine/block-root/src/widgets/index.ts @@ -4,15 +4,6 @@ export { EDGELESS_ELEMENT_TOOLBAR_WIDGET, EdgelessElementToolbarWidget, } from './element-toolbar/index.js'; -export { - AFFINE_EMBED_CARD_TOOLBAR_WIDGET, - EmbedCardToolbar, -} from './embed-card-toolbar/embed-card-toolbar.js'; -export { toolbarDefaultConfig } from './format-bar/config.js'; -export { - AFFINE_FORMAT_BAR_WIDGET, - AffineFormatBarWidget, -} from './format-bar/format-bar.js'; export { AffineImageToolbarWidget } from './image-toolbar/index.js'; export { AffineInnerModalWidget } from './inner-modal/inner-modal.js'; export * from './keyboard-toolbar/index.js'; diff --git a/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json index 2dbee20831..bfb944c927 100644 --- a/blocksuite/affine/components/package.json +++ b/blocksuite/affine/components/package.json @@ -63,7 +63,12 @@ "./block-zero-width": "./src/block-zero-width/index.ts", "./block-selection": "./src/block-selection/index.ts", "./doc-title": "./src/doc-title/index.ts", - "./embed-card-modal": "./src/embed-card-modal/index.ts" + "./embed-card-modal": "./src/embed-card-modal/index.ts", + "./link-preview": "./src/link-preview/index.ts", + "./linked-doc-title": "./src/linked-doc-title/index.ts", + "./view-dropdown-menu": "./src/view-dropdown-menu/index.ts", + "./card-style-dropdown-menu": "./src/card-style-dropdown-menu/index.ts", + "./highlight-dropdown-menu": "./src/highlight-dropdown-menu/index.ts" }, "files": [ "src", diff --git a/blocksuite/affine/components/src/card-style-dropdown-menu/dropdown-menu.ts b/blocksuite/affine/components/src/card-style-dropdown-menu/dropdown-menu.ts new file mode 100644 index 0000000000..b483fc2bb2 --- /dev/null +++ b/blocksuite/affine/components/src/card-style-dropdown-menu/dropdown-menu.ts @@ -0,0 +1,115 @@ +import type { ColorScheme } from '@blocksuite/affine-model'; +import { + type ToolbarAction, + ToolbarContext, +} from '@blocksuite/affine-shared/services'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { PaletteIcon } from '@blocksuite/icons/lit'; +import { + computed, + type ReadonlySignal, + type Signal, +} from '@preact/signals-core'; +import { property } from 'lit/decorators.js'; +import { html, type TemplateResult } from 'lit-html'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { repeat } from 'lit-html/directives/repeat.js'; + +import { + EmbedCardDarkHorizontalIcon, + EmbedCardDarkListIcon, + EmbedCardLightHorizontalIcon, + EmbedCardLightListIcon, +} from '../icons'; + +const cardStyleMap: Record> = { + light: { + horizontal: EmbedCardLightHorizontalIcon, + list: EmbedCardLightListIcon, + }, + dark: { + horizontal: EmbedCardDarkHorizontalIcon, + list: EmbedCardDarkListIcon, + }, +}; + +@requiredProperties({ + actions: PropTypes.array, + context: PropTypes.instanceOf(ToolbarContext), + style$: PropTypes.object, +}) +export class CardStyleDropdownMenu extends SignalWatcher(ShadowlessElement) { + @property({ attribute: false }) + accessor actions!: ToolbarAction[]; + + @property({ attribute: false }) + accessor context!: ToolbarContext; + + @property({ attribute: false }) + accessor style$!: Signal | ReadonlySignal; + + @property({ attribute: false }) + accessor toggle: ((e: CustomEvent) => void) | undefined; + + icons$ = computed( + () => cardStyleMap[this.context.themeProvider.theme$.value] + ); + + override render() { + const { + actions, + context, + toggle, + style$: { value: style }, + icons$: { value: icons }, + } = this; + + return html` + + ${PaletteIcon()} + + `} + > +
+ ${repeat( + actions, + action => action.id, + ({ id, label, icon, disabled, run }) => html` + run?.(context)} + > + ${icon || icons[id]} + + ` + )} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-card-style-dropdown-menu': CardStyleDropdownMenu; + } +} diff --git a/blocksuite/affine/components/src/card-style-dropdown-menu/index.ts b/blocksuite/affine/components/src/card-style-dropdown-menu/index.ts new file mode 100644 index 0000000000..2cb2cb72af --- /dev/null +++ b/blocksuite/affine/components/src/card-style-dropdown-menu/index.ts @@ -0,0 +1,10 @@ +import { CardStyleDropdownMenu } from './dropdown-menu'; + +export * from './dropdown-menu'; + +export function effects() { + customElements.define( + 'affine-card-style-dropdown-menu', + CardStyleDropdownMenu + ); +} diff --git a/blocksuite/affine/components/src/embed-card-modal/embed-card-edit-modal.ts b/blocksuite/affine/components/src/embed-card-modal/embed-card-edit-modal.ts index 4714fa2b8b..d7ff6c4ba4 100644 --- a/blocksuite/affine/components/src/embed-card-modal/embed-card-edit-modal.ts +++ b/blocksuite/affine/components/src/embed-card-modal/embed-card-edit-modal.ts @@ -20,7 +20,11 @@ import type { BlockStdScope, EditorHost, } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + nextTick, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom'; import { computed, signal } from '@preact/signals-core'; import { css, html, LitElement } from 'lit'; @@ -137,6 +141,7 @@ export class EmbedCardEditModal extends SignalWatcher( private readonly _hide = () => { this.remove(); + this.abortController?.abort(); }; private readonly _onKeydown = (e: KeyboardEvent) => { @@ -146,7 +151,7 @@ export class EmbedCardEditModal extends SignalWatcher( } if (e.key === 'Escape') { e.preventDefault(); - this.remove(); + this._hide(); } }; @@ -154,7 +159,7 @@ export class EmbedCardEditModal extends SignalWatcher( const blockComponent = this._blockComponent; if (!blockComponent) { - this.remove(); + this._hide(); return; } @@ -168,14 +173,14 @@ export class EmbedCardEditModal extends SignalWatcher( track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' }); - this.remove(); + this._hide(); }; private readonly _onSave = () => { const blockComponent = this._blockComponent; if (!blockComponent) { - this.remove(); + this._hide(); return; } @@ -196,7 +201,7 @@ export class EmbedCardEditModal extends SignalWatcher( track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' }); - this.remove(); + this._hide(); }; private readonly _updateDescription = (e: InputEvent) => { @@ -278,7 +283,10 @@ export class EmbedCardEditModal extends SignalWatcher( }) ); - this.disposables.add(listenClickAway(this, this._hide)); + // Resolves the click event is triggered after the first rendering. + nextTick() + .then(() => this.disposables.add(listenClickAway(this, this._hide))) + .catch(console.error); this.disposables.addFromEvent(this, 'keydown', this._onKeydown); this.disposables.addFromEvent(this, 'pointerdown', stopPropagation); this.disposables.addFromEvent(this, 'cut', stopPropagation); @@ -401,6 +409,9 @@ export class EmbedCardEditModal extends SignalWatcher( @property({ attribute: false }) accessor viewType!: string; + + @property({ attribute: false }) + accessor abortController: AbortController | undefined = undefined; } export function toggleEmbedCardEditModal( @@ -413,7 +424,8 @@ export function toggleEmbedCardEditModal( std: BlockStdScope, component: BlockComponent, props: AliasInfo - ) => void + ) => void, + abortController?: AbortController ) { document.body.querySelector('embed-card-edit-modal')?.remove(); @@ -424,6 +436,7 @@ export function toggleEmbedCardEditModal( embedCardEditModal.originalDocInfo = originalDocInfo; embedCardEditModal.onReset = onReset; embedCardEditModal.onSave = onSave; + embedCardEditModal.abortController = abortController; document.body.append(embedCardEditModal); } diff --git a/blocksuite/affine/components/src/highlight-dropdown-menu/dropdown-menu.ts b/blocksuite/affine/components/src/highlight-dropdown-menu/dropdown-menu.ts new file mode 100644 index 0000000000..13c6ab3673 --- /dev/null +++ b/blocksuite/affine/components/src/highlight-dropdown-menu/dropdown-menu.ts @@ -0,0 +1,120 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { PropTypes, requiredProperties } from '@blocksuite/block-std'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/lit'; +import { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit-html'; +import { repeat } from 'lit-html/directives/repeat.js'; + +const colors = [ + 'default', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', +] as const; + +type HighlightType = 'color' | 'background'; + +// TODO(@fundon): these recent settings should be added to the dropdown menu +// blocksuite/tests-legacy/e2e/format-bar.spec.ts#253 +// +// let latestHighlightColor: string | null = null; +// let latestHighlightType: HighlightType = 'background'; + +@requiredProperties({ + updateHighlight: PropTypes.instanceOf(Function), +}) +export class HighlightDropdownMenu extends LitElement { + @property({ attribute: false }) + accessor updateHighlight!: (styles: AffineTextAttributes) => void; + + private readonly _update = (value: string | null, type: HighlightType) => { + // latestHighlightColor = value; + // latestHighlightType = type; + + this.updateHighlight({ [`${type}`]: value }); + }; + + override render() { + const prefix = '--affine-text-highlight'; + + return html` + + + + ${ArrowDownSmallIcon()} + + `} + > +
+
Color
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault + ? null + : `var(${prefix}-foreground-${color})`; + return html` + this._update(value, 'color')} + > + + ${isDefault ? `${color} color` : color} + + `; + })} + +
Background
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault ? null : `var(${prefix}-${color})`; + return html` + this._update(value, 'background')} + > + + + ${isDefault ? `${color} background` : color} + + `; + })} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-highlight-dropdown-menu': HighlightDropdownMenu; + } +} diff --git a/blocksuite/affine/components/src/highlight-dropdown-menu/highlight-duotone-icon.ts b/blocksuite/affine/components/src/highlight-dropdown-menu/highlight-duotone-icon.ts new file mode 100644 index 0000000000..326609db93 --- /dev/null +++ b/blocksuite/affine/components/src/highlight-dropdown-menu/highlight-duotone-icon.ts @@ -0,0 +1,23 @@ +import { HighLightDuotoneIcon } from '@blocksuite/icons/lit'; +import { css, LitElement } from 'lit'; + +export class HighlightDuotoneIcon extends LitElement { + static override styles = css` + svg { + display: flex; + font-size: 20px; + } + svg > path:nth-child(1) { + fill: var(--color, unset); + } + `; + override render() { + return HighLightDuotoneIcon(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-highlight-duotone-icon': HighlightDuotoneIcon; + } +} diff --git a/blocksuite/affine/components/src/highlight-dropdown-menu/index.ts b/blocksuite/affine/components/src/highlight-dropdown-menu/index.ts new file mode 100644 index 0000000000..25c0ad1078 --- /dev/null +++ b/blocksuite/affine/components/src/highlight-dropdown-menu/index.ts @@ -0,0 +1,16 @@ +import { HighlightDropdownMenu } from './dropdown-menu'; +import { HighlightDuotoneIcon } from './highlight-duotone-icon'; +import { TextDuotoneIcon } from './text-duotone-icon'; + +export * from './dropdown-menu'; +export * from './highlight-duotone-icon'; +export * from './text-duotone-icon'; + +export function effects() { + customElements.define( + 'affine-highlight-dropdown-menu', + HighlightDropdownMenu + ); + customElements.define('affine-highlight-duotone-icon', HighlightDuotoneIcon); + customElements.define('affine-text-duotone-icon', TextDuotoneIcon); +} diff --git a/blocksuite/affine/components/src/highlight-dropdown-menu/text-duotone-icon.ts b/blocksuite/affine/components/src/highlight-dropdown-menu/text-duotone-icon.ts new file mode 100644 index 0000000000..9c93143af1 --- /dev/null +++ b/blocksuite/affine/components/src/highlight-dropdown-menu/text-duotone-icon.ts @@ -0,0 +1,26 @@ +import { TextBackgroundDuotoneIcon } from '@blocksuite/icons/lit'; +import { css, LitElement } from 'lit'; + +export class TextDuotoneIcon extends LitElement { + static override styles = css` + svg { + display: flex; + font-size: 20px; + } + svg > path:nth-child(1) { + fill: var(--background, unset); + } + svg > path:nth-child(3) { + fill: var(--color, unset); + } + `; + override render() { + return TextBackgroundDuotoneIcon(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-text-duotone-icon': TextDuotoneIcon; + } +} diff --git a/blocksuite/affine/components/src/hover/middlewares/basic.ts b/blocksuite/affine/components/src/hover/middlewares/basic.ts index 66a15748fd..a62eeaccb8 100644 --- a/blocksuite/affine/components/src/hover/middlewares/basic.ts +++ b/blocksuite/affine/components/src/hover/middlewares/basic.ts @@ -12,7 +12,7 @@ export const dedupe = (keepWhenFloatingNotReady = true): HoverMiddleware => { let hoverState = false; return ({ event, floatingElement }) => { const curState = hoverState; - if (event.type === 'mouseover') { + if (event.type === 'mouseenter') { // hover in hoverState = true; if (curState !== hoverState) @@ -55,7 +55,7 @@ export const delayShow = (delay: number): HoverMiddleware => { abortController.abort(); const newAbortController = new AbortController(); abortController = newAbortController; - if (event.type !== 'mouseover') return true; + if (event.type !== 'mouseenter') return true; if (delay <= 0) return true; await sleep(delay, newAbortController.signal); return !newAbortController.signal.aborted; diff --git a/blocksuite/affine/components/src/hover/when-hover.ts b/blocksuite/affine/components/src/hover/when-hover.ts index 4c8959a28c..57a79cdb4a 100644 --- a/blocksuite/affine/components/src/hover/when-hover.ts +++ b/blocksuite/affine/components/src/hover/when-hover.ts @@ -80,7 +80,7 @@ export const whenHover = ( } // ignore expired event if (e !== currentEvent) return; - const isHover = e.type === 'mouseover' ? true : false; + const isHover = e.type === 'mouseenter' ? true : false; whenHoverChange(isHover, e); }) as (e: Event) => void; @@ -90,9 +90,9 @@ export const whenHover = ( const alreadyHover = element.matches(':hover'); if (alreadyHover && !abortController.signal.aborted) { // When the element is already hovered, we need to trigger the callback manually - onHoverChange(new MouseEvent('mouseover')); + onHoverChange(new MouseEvent('mouseenter')); } - element.addEventListener('mouseover', onHoverChange, { + element.addEventListener('mouseenter', onHoverChange, { capture: true, signal: abortController.signal, }); @@ -112,7 +112,9 @@ export const whenHover = ( const removeHoverListener = (element?: Element) => { if (!element) return; - element.removeEventListener('mouseover', onHoverChange); + element.removeEventListener('mouseenter', onHoverChange, { + capture: true, + }); element.removeEventListener('mouseleave', onHoverChange); }; diff --git a/blocksuite/affine/components/src/link-preview/index.ts b/blocksuite/affine/components/src/link-preview/index.ts new file mode 100644 index 0000000000..7b8f1bc2c4 --- /dev/null +++ b/blocksuite/affine/components/src/link-preview/index.ts @@ -0,0 +1,7 @@ +import { LinkPreview } from './link'; + +export * from './link'; + +export function effects() { + customElements.define('affine-link-preview', LinkPreview); +} diff --git a/blocksuite/affine/components/src/link-preview/link.ts b/blocksuite/affine/components/src/link-preview/link.ts new file mode 100644 index 0000000000..f7e7f9f493 --- /dev/null +++ b/blocksuite/affine/components/src/link-preview/link.ts @@ -0,0 +1,73 @@ +import { getHostName } from '@blocksuite/affine-shared/utils'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; + +@requiredProperties({ + url: PropTypes.string, +}) +export class LinkPreview extends ShadowlessElement { + static override styles = css` + .affine-link-preview { + display: flex; + justify-content: flex-start; + min-width: 60px; + max-width: 140px; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + user-select: none; + cursor: pointer; + + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + + .affine-link-preview > span { + display: inline-block; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + text-overflow: ellipsis; + overflow: hidden; + opacity: var(--add, 1); + } + `; + + @property({ attribute: false }) + accessor url!: string; + + override render() { + const { url } = this; + + return html` + + ${getHostName(url)} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-link-preview': LinkPreview; + } +} diff --git a/blocksuite/affine/components/src/linked-doc-title/doc-title.ts b/blocksuite/affine/components/src/linked-doc-title/doc-title.ts new file mode 100644 index 0000000000..aa920a0639 --- /dev/null +++ b/blocksuite/affine/components/src/linked-doc-title/doc-title.ts @@ -0,0 +1,65 @@ +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; + +@requiredProperties({ + title: PropTypes.string, + open: PropTypes.instanceOf(Function), +}) +export class DocTitle extends ShadowlessElement { + static override styles = css` + editor-icon-button .label { + min-width: 60px; + max-width: 140px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + `; + + @property({ attribute: false }) + override accessor title!: string; + + @property({ attribute: false }) + accessor open!: (event: MouseEvent) => void; + + override render() { + const { title, open } = this; + + return html` + open(event)} + > + ${title} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-linked-doc-title': DocTitle; + } +} diff --git a/blocksuite/affine/components/src/linked-doc-title/index.ts b/blocksuite/affine/components/src/linked-doc-title/index.ts new file mode 100644 index 0000000000..e921a18a9a --- /dev/null +++ b/blocksuite/affine/components/src/linked-doc-title/index.ts @@ -0,0 +1,7 @@ +import { DocTitle } from './doc-title'; + +export * from './doc-title'; + +export function effects() { + customElements.define('affine-linked-doc-title', DocTitle); +} diff --git a/blocksuite/affine/components/src/rich-text/effects.ts b/blocksuite/affine/components/src/rich-text/effects.ts index 59c8b7e307..7ff84171be 100644 --- a/blocksuite/affine/components/src/rich-text/effects.ts +++ b/blocksuite/affine/components/src/rich-text/effects.ts @@ -10,8 +10,7 @@ import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor- import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js'; import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js'; import { LinkPopup } from './inline/presets/nodes/link-node/link-popup/link-popup.js'; -import { ReferenceAliasPopup } from './inline/presets/nodes/reference-node/reference-alias-popup.js'; -import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup.js'; +import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup/reference-popup.js'; import { RichText } from './rich-text.js'; export function effects() { @@ -23,7 +22,6 @@ export function effects() { customElements.define('link-popup', LinkPopup); customElements.define('affine-link', AffineLink); customElements.define('reference-popup', ReferencePopup); - customElements.define('reference-alias-popup', ReferenceAliasPopup); customElements.define('affine-reference', AffineReference); customElements.define('affine-footnote-node', AffineFootnoteNode); customElements.define('footnote-popup', FootNotePopup); @@ -41,7 +39,6 @@ declare global { 'affine-text': AffineText; 'rich-text': RichText; 'reference-popup': ReferencePopup; - 'reference-alias-popup': ReferenceAliasPopup; 'latex-editor-unit': LatexEditorUnit; 'latex-editor-menu': LatexEditorMenu; 'link-popup': LinkPopup; diff --git a/blocksuite/affine/components/src/rich-text/format/text-style.ts b/blocksuite/affine/components/src/rich-text/format/text-style.ts index 64feb32fe7..29abb1f54a 100644 --- a/blocksuite/affine/components/src/rich-text/format/text-style.ts +++ b/blocksuite/affine/components/src/rich-text/format/text-style.ts @@ -68,7 +68,7 @@ export const toggleUnderline = toggleTextStyleCommandWrapper('underline'); export const toggleStrike = toggleTextStyleCommandWrapper('strike'); export const toggleCode = toggleTextStyleCommandWrapper('code'); -export const toggleLink: Command = (_ctx, next) => { +export const toggleLink: Command = (ctx, next) => { const selection = document.getSelection(); if (!selection || selection.rangeCount === 0) return false; @@ -92,8 +92,9 @@ export const toggleLink: Command = (_ctx, next) => { const abortController = new AbortController(); const popup = toggleLinkPopup( - inlineEditor, + ctx.std, 'create', + inlineEditor, targetInlineRange, abortController ); diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts index e94a9d1184..e6c3dee572 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts @@ -1,12 +1,15 @@ import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; -import { StdIdentifier } from '@blocksuite/block-std'; +import { BlockFlavourIdentifier, StdIdentifier } from '@blocksuite/block-std'; import type { InlineEditor, InlineRootElement } from '@blocksuite/inline'; import { html } from 'lit'; import { z } from 'zod'; import { InlineSpecExtension } from '../../extension/index.js'; import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js'; +import { builtinInlineLinkToolbarConfig } from './nodes/link-node/configs/toolbar.js'; +import { builtinInlineReferenceToolbarConfig } from './nodes/reference-node/configs/toolbar.js'; import { ReferenceNodeConfigIdentifier, ReferenceNodeConfigProvider, @@ -220,4 +223,14 @@ export const InlineSpecExtensions = [ LinkInlineSpecExtension, LatexEditorUnitSpecExtension, FootNoteInlineSpecExtension, + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:reference'), + config: builtinInlineReferenceToolbarConfig, + }), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:link'), + config: builtinInlineLinkToolbarConfig, + }), ]; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts index c316fd4c8a..deeb48450b 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts @@ -2,9 +2,4 @@ export { affineTextStyles } from './affine-text.js'; export * from './footnote-node/footnote-config.js'; export { AffineFootnoteNode } from './footnote-node/footnote-node.js'; export { AffineLink, toggleLinkPopup } from './link-node/index.js'; -export * from './reference-node/reference-config.js'; -export { AffineReference } from './reference-node/reference-node.js'; -export type { - DocLinkClickedEvent, - RefNodeSlots, -} from './reference-node/types.js'; +export * from './reference-node/index.js'; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts index 76e8af258d..de8b1bb9bb 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts @@ -1,13 +1,12 @@ import type { ReferenceInfo } from '@blocksuite/affine-model'; -import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services'; +import { + ParseDocUrlProvider, + ToolbarRegistryIdentifier, +} from '@blocksuite/affine-shared/services'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std'; -import { - BLOCK_ID_ATTR, - BlockSelection, - ShadowlessElement, - TextSelection, -} from '@blocksuite/block-std'; +import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; import { type DeltaInsert, INLINE_ROOT_ATTR, @@ -16,15 +15,13 @@ import { } from '@blocksuite/inline'; import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { HoverController } from '../../../../../hover/index.js'; -import { RefNodeSlotsProvider } from '../../../../extension/index.js'; -import { affineTextStyles } from '../affine-text.js'; -import { toggleLinkPopup } from './link-popup/toggle-link-popup.js'; +import { whenHover } from '../../../../../hover/index'; +import { RefNodeSlotsProvider } from '../../../../extension/index'; +import { affineTextStyles } from '../affine-text'; -export class AffineLink extends ShadowlessElement { +export class AffineLink extends WithDisposable(ShadowlessElement) { static override styles = css` affine-link a:hover [data-v-text='true'] { text-decoration: underline; @@ -66,43 +63,41 @@ export class AffineLink extends ShadowlessElement { }); }; - private readonly _whenHover = new HoverController( - this, - ({ abortController }) => { - if (this.block?.doc.readonly) { - return null; - } - if (!this.inlineEditor || !this.selfInlineRange) { - return null; + _whenHover = whenHover( + hovered => { + const message$ = this.std.get(ToolbarRegistryIdentifier).message$; + + if (hovered) { + message$.value = { + flavour: 'affine:link', + element: this, + setFloating: this._whenHover.setFloating, + }; + return; } - const selection = this.std.selection; - const textSelection = selection?.find(TextSelection); - if (!!textSelection && !textSelection.isCollapsed()) { - return null; - } - - const blockSelections = selection?.filter(BlockSelection); - if (blockSelections?.length) { - return null; - } - - return { - template: toggleLinkPopup( - this.inlineEditor, - 'view', - this.selfInlineRange, - abortController, - (e?: MouseEvent) => { - this.openLink(e); - abortController.abort(); - } - ), - }; + // Clears previous bindings + message$.value = null; + this._whenHover.setFloating(); }, { enterDelay: 500 } ); + override connectedCallback() { + super.connectedCallback(); + + this._whenHover.setReference(this); + + const message$ = this.std.get(ToolbarRegistryIdentifier).message$; + + this._disposables.add(() => { + if (message$?.value) { + message$.value = null; + } + this._whenHover.dispose(); + }); + } + // Workaround for links not working in contenteditable div // see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div // @@ -149,7 +144,6 @@ export class AffineLink extends ShadowlessElement { private _renderLink(style: StyleInfo) { return html``; + }, + }, + { + id: 'b.copy-link-and-edit', + actions: [ + { + id: 'copy-link', + tooltip: 'Copy link', + icon: CopyIcon(), + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + + const { link } = target; + + if (!link) return; + + // Clears + ctx.reset(); + + navigator.clipboard.writeText(link).catch(console.error); + toast(ctx.host, 'Copied link to clipboard'); + + ctx.track('CopiedLink', { + ...trackBaseProps, + control: 'copy link', + }); + }, + }, + { + id: 'edit', + tooltip: 'Edit', + icon: EditIcon(), + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + + const { inlineEditor, selfInlineRange } = target; + + if (!inlineEditor || !selfInlineRange) return; + + const abortController = new AbortController(); + const popover = toggleLinkPopup( + ctx.std, + 'edit', + inlineEditor, + selfInlineRange, + abortController + ); + abortController.signal.onabort = () => popover.remove(); + + ctx.track('OpenedAliasPopup', { + ...trackBaseProps, + control: 'edit', + }); + }, + }, + ], + }, + { + id: 'c.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + disabled: true, + }, + { + id: 'card', + label: 'Card view', + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + if (!target.block) return; + + const { + block: { model }, + inlineEditor, + selfInlineRange, + } = target; + const { parent } = model; + + if (!inlineEditor || !selfInlineRange || !parent) return; + + const url = inlineEditor.getFormat(selfInlineRange).link; + if (!url) return; + + // Clears + ctx.reset(); + + const title = inlineEditor.yTextString.slice( + selfInlineRange.index, + selfInlineRange.index + selfInlineRange.length + ); + + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + const flavour = + options?.viewType === 'card' + ? options.flavour + : 'affine:bookmark'; + const index = parent.children.indexOf(model); + const props = { + url, + title: title === url ? '' : title, + }; + + const blockId = ctx.store.addBlock( + flavour, + props, + parent, + index + 1 + ); + + const totalTextLength = inlineEditor.yTextLength; + const inlineTextLength = selfInlineRange.length; + if (totalTextLength === inlineTextLength) { + ctx.store.deleteBlock(model); + } else { + inlineEditor.formatText(selfInlineRange, { link: null }); + } + + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'card view', + }); + }, + }, + { + id: 'embed', + label: 'Embed view', + when(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return false; + if (!target.block) return false; + + const { + block: { model }, + inlineEditor, + selfInlineRange, + } = target; + const { parent } = model; + + if (!inlineEditor || !selfInlineRange || !parent) return false; + + const url = inlineEditor.getFormat(selfInlineRange).link; + if (!url) return false; + + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + return options?.viewType === 'embed'; + }, + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + if (!target.block) return; + + const { + block: { model }, + inlineEditor, + selfInlineRange, + } = target; + const { parent } = model; + + if (!inlineEditor || !selfInlineRange || !parent) return; + + const url = inlineEditor.getFormat(selfInlineRange).link; + if (!url) return; + + // Clears + ctx.reset(); + + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + if (options?.viewType !== 'embed') return; + + const flavour = options.flavour; + const index = parent.children.indexOf(model); + const props = { url }; + + const blockId = ctx.store.addBlock( + flavour, + props, + parent, + index + 1 + ); + + const totalTextLength = inlineEditor.yTextLength; + const inlineTextLength = selfInlineRange.length; + if (totalTextLength === inlineTextLength) { + ctx.store.deleteBlock(model); + } else { + inlineEditor.formatText(selfInlineRange, { link: null }); + } + + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return null; + + const actions = this.actions.map(action => ({ ...action })); + const viewType$ = signal(actions[0].label); + const toggle = (e: CustomEvent) => { + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html`${keyed( + target, + html`` + )}`; + }, + when(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return false; + if (!target.block) return false; + + if (ctx.flags.isNative()) return false; + if ( + target.block.closest('affine-database') || + target.block.closest('affine-table') + ) + return false; + + const { model } = target.block; + const parent = model.parent; + if (!parent) return false; + + const schema = ctx.store.schema; + const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark'); + if (!bookmarkSchema) return false; + + const parentSchema = schema.flavourSchemaMap.get(parent.flavour); + if (!parentSchema) return false; + + try { + schema.validateSchema(bookmarkSchema, parentSchema); + } catch { + return false; + } + + return true; + }, + } satisfies ToolbarActionGroup, + { + placement: ActionPlacement.More, + id: 'b.remove-link', + label: 'Remove link', + icon: UnlinkIcon(), + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + + const { inlineEditor, selfInlineRange } = target; + if (!inlineEditor || !selfInlineRange) return; + + if (!inlineEditor.isValidInlineRange(selfInlineRange)) return; + + inlineEditor.formatText(selfInlineRange, { link: null }); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const target = ctx.message$.peek()?.element; + if (!(target instanceof AffineLink)) return; + + const { inlineEditor, selfInlineRange } = target; + if (!inlineEditor || !selfInlineRange) return; + + if (!inlineEditor.isValidInlineRange(selfInlineRange)) return; + + inlineEditor.deleteText(selfInlineRange); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts index c2c462c808..0ef80f8582 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts @@ -1,48 +1,20 @@ import { - EmbedOptionProvider, - type LinkEventType, - type TelemetryEvent, - TelemetryProvider, -} from '@blocksuite/affine-shared/services'; -import type { EmbedOptions } from '@blocksuite/affine-shared/types'; -import { - getHostName, isValidUrl, normalizeUrl, stopPropagation, } from '@blocksuite/affine-shared/utils'; -import { - BLOCK_ID_ATTR, - type BlockComponent, - type BlockStdScope, - TextSelection, -} from '@blocksuite/block-std'; +import { type BlockStdScope, TextSelection } from '@blocksuite/block-std'; import { WithDisposable } from '@blocksuite/global/utils'; -import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit'; +import { DoneIcon } from '@blocksuite/icons/lit'; import type { InlineRange } from '@blocksuite/inline/types'; import { computePosition, inline, offset, shift } from '@floating-ui/dom'; -import { html, LitElement, nothing } from 'lit'; +import { html, LitElement } from 'lit'; import { property, query } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; -import { join } from 'lit/directives/join.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { - ConfirmIcon, - CopyIcon, - DeleteIcon, - EditIcon, - OpenIcon, - UnlinkIcon, -} from '../../../../../../icons/index.js'; -import { toast } from '../../../../../../toast/index.js'; -import type { EditorIconButton } from '../../../../../../toolbar/index.js'; -import { - renderActions, - renderToolbarSeparator, -} from '../../../../../../toolbar/index.js'; -import type { AffineInlineEditor } from '../../../affine-inline-specs.js'; -import { linkPopupStyle } from './styles.js'; +import type { EditorIconButton } from '../../../../../../toolbar/index'; +import type { AffineInlineEditor } from '../../../affine-inline-specs'; +import { linkPopupStyle } from './styles'; export class LinkPopup extends WithDisposable(LitElement) { static override styles = linkPopupStyle; @@ -74,21 +46,6 @@ export class LinkPopup extends WithDisposable(LitElement) { `; }; - private readonly _delete = () => { - if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { - this.inlineEditor.deleteText(this.targetInlineRange); - } - this.abortController.abort(); - }; - - private readonly _edit = () => { - if (!this.host) return; - - this.type = 'edit'; - - track(this.host.std, 'OpenedAliasPopup', { control: 'edit' }); - }; - private readonly _editTemplate = () => { this.updateComplete .then(() => { @@ -137,154 +94,6 @@ export class LinkPopup extends WithDisposable(LitElement) { `; }; - private _embedOptions: EmbedOptions | null = null; - - private readonly _openLink = () => { - if (this.openLink) { - this.openLink(); - return; - } - - let link = this.currentLink; - if (!link) return; - if (!link.match(/^[a-zA-Z]+:\/\//)) { - link = 'https://' + link; - } - window.open(link, '_blank'); - this.abortController.abort(); - }; - - private readonly _removeLink = () => { - if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { - this.inlineEditor.formatText(this.targetInlineRange, { - link: null, - }); - } - this.abortController.abort(); - }; - - private readonly _toggleViewSelector = (e: Event) => { - if (!this.host) return; - - const opened = (e as CustomEvent).detail; - if (!opened) return; - - track(this.host.std, 'OpenedViewSelector', { control: 'switch view' }); - }; - - private readonly _trackViewSelected = (type: string) => { - if (!this.host) return; - - track(this.host.std, 'SelectedView', { - control: 'select view', - type: `${type} view`, - }); - }; - - private readonly _viewTemplate = () => { - if (!this.currentLink) return; - - this._embedOptions = - this.std - ?.get(EmbedOptionProvider) - .getEmbedBlockOptions(this.currentLink) ?? null; - - const buttons = [ - html` - this.openLink?.(e)} - > - ${getHostName(this.currentLink)} - - - - ${CopyIcon} - - - - ${EditIcon} - - `, - - this._viewSelector(), - - html` - - ${MoreVerticalIcon()} - - `} - > -
- ${this._moreActions()} -
-
- `, - ]; - - return html` - - ${join( - buttons.filter(button => button !== nothing), - renderToolbarSeparator - )} - - `; - }; - - private get _canConvertToEmbedView() { - return this._embedOptions?.viewType === 'embed'; - } - - private get _isBookmarkAllowed() { - const block = this.block; - if (!block) return false; - const schema = block.doc.schema; - const parent = block.doc.getParent(block.model); - if (!parent) return false; - const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark'); - if (!bookmarkSchema) return false; - const parentSchema = schema.flavourSchemaMap.get(parent.flavour); - if (!parentSchema) return false; - - try { - schema.validateSchema(bookmarkSchema, parentSchema); - } catch { - return false; - } - - return true; - } - - get block() { - const { rootElement } = this.inlineEditor; - if (!rootElement) return null; - - const block = rootElement.closest(`[${BLOCK_ID_ATTR}]`); - if (!block) return null; - return block; - } - get currentLink() { return this.inlineEditor.getFormat(this.targetInlineRange).link; } @@ -296,137 +105,19 @@ export class LinkPopup extends WithDisposable(LitElement) { ); } - get host() { - return this.block?.host; - } - - get std() { - return this.block?.std; - } - private _confirmBtnTemplate() { return html` - ${ConfirmIcon} + ${DoneIcon()} `; } - private _convertToCardView() { - if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { - return; - } - - let targetFlavour = 'affine:bookmark'; - - if (this._embedOptions && this._embedOptions.viewType === 'card') { - targetFlavour = this._embedOptions.flavour; - } - - const block = this.block; - if (!block) return; - const url = this.currentLink; - const title = this.currentText; - const props = { - url, - title: title === url ? '' : title, - }; - const doc = block.doc; - const parent = doc.getParent(block.model); - if (!parent) return; - const index = parent.children.indexOf(block.model); - doc.addBlock(targetFlavour as never, props, parent, index + 1); - - const totalTextLength = this.inlineEditor.yTextLength; - const inlineTextLength = this.targetInlineRange.length; - if (totalTextLength === inlineTextLength) { - doc.deleteBlock(block.model); - } else { - this.inlineEditor.formatText(this.targetInlineRange, { link: null }); - } - - this.abortController.abort(); - } - - private _convertToEmbedView() { - if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { - return; - } - - const { flavour } = this._embedOptions; - const url = this.currentLink; - - const block = this.block; - if (!block) return; - const doc = block.doc; - const parent = doc.getParent(block.model); - if (!parent) return; - const index = parent.children.indexOf(block.model); - - doc.addBlock(flavour as never, { url }, parent, index + 1); - - const totalTextLength = this.inlineEditor.yTextLength; - const inlineTextLength = this.targetInlineRange.length; - if (totalTextLength === inlineTextLength) { - doc.deleteBlock(block.model); - } else { - this.inlineEditor.formatText(this.targetInlineRange, { link: null }); - } - - this.abortController.abort(); - } - - private _copyUrl() { - if (!this.currentLink) return; - navigator.clipboard.writeText(this.currentLink).catch(console.error); - if (!this.host) return; - toast(this.host, 'Copied link to clipboard'); - this.abortController.abort(); - - track(this.host.std, 'CopiedLink', { control: 'copy link' }); - } - - private _moreActions() { - return renderActions([ - [ - { - label: 'Open', - type: 'open', - icon: OpenIcon, - action: this._openLink, - }, - - { - label: 'Copy', - type: 'copy', - icon: CopyIcon, - action: this._copyUrl, - }, - - { - label: 'Remove link', - type: 'remove-link', - icon: UnlinkIcon, - action: this._removeLink, - }, - ], - - [ - { - type: 'delete', - label: 'Delete', - icon: DeleteIcon, - action: this._delete, - }, - ], - ]); - } - private _onConfirm() { if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return; if (!this.linkInput) return; @@ -442,10 +133,6 @@ export class LinkPopup extends WithDisposable(LitElement) { reference: null, }); this.inlineEditor.setInlineRange(this.targetInlineRange); - const textSelection = this.host?.selection.find(TextSelection); - if (!textSelection) return; - - this.std?.range.syncTextSelectionToRange(textSelection); } else if (this.type === 'edit') { const text = this.textInput?.value ?? link; this.inlineEditor.insertText(this.targetInlineRange, text, { @@ -456,10 +143,11 @@ export class LinkPopup extends WithDisposable(LitElement) { index: this.targetInlineRange.index, length: text.length, }); - const textSelection = this.host?.selection.find(TextSelection); - if (!textSelection) return; + } - this.std?.range.syncTextSelectionToRange(textSelection); + const textSelection = this.std.host.selection.find(TextSelection); + if (textSelection) { + this.std.range.syncTextSelectionToRange(textSelection); } this.abortController.abort(); @@ -467,9 +155,17 @@ export class LinkPopup extends WithDisposable(LitElement) { private _onKeydown(e: KeyboardEvent) { e.stopPropagation(); - if (e.key === 'Enter' && !e.isComposing) { - e.preventDefault(); - this._onConfirm(); + if (!e.isComposing) { + if (e.key === 'Escape') { + e.preventDefault(); + this.abortController.abort(); + this.std.host.selection.clear(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + this._onConfirm(); + } } } @@ -484,70 +180,6 @@ export class LinkPopup extends WithDisposable(LitElement) { this.confirmButton.requestUpdate(); } - private _viewSelector() { - if (!this._isBookmarkAllowed) return nothing; - - const buttons = []; - - buttons.push({ - type: 'inline', - label: 'Inline view', - }); - - buttons.push({ - type: 'card', - label: 'Card view', - action: () => this._convertToCardView(), - }); - - if (this._canConvertToEmbedView) { - buttons.push({ - type: 'embed', - label: 'Embed view', - action: () => this._convertToEmbedView(), - }); - } - - return html` - -
Inline view
- ${ArrowDownSmallIcon()} - - `} - @toggle=${this._toggleViewSelector} - > -
- ${repeat( - buttons, - button => button.type, - ({ type, label, action }) => html` - { - action?.(); - this._trackViewSelected(type); - }} - > - ${label} - - ` - )} -
-
- `; - } - override connectedCallback() { super.connectedCallback(); @@ -555,45 +187,38 @@ export class LinkPopup extends WithDisposable(LitElement) { return; } - if (this.type === 'edit' || this.type === 'create') { - // disable body scroll - this._bodyOverflowStyle = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - this.disposables.add({ - dispose: () => { - document.body.style.overflow = this._bodyOverflowStyle; - }, - }); - } + // disable body scroll + this._bodyOverflowStyle = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + this.disposables.add({ + dispose: () => { + document.body.style.overflow = this._bodyOverflowStyle; + }, + }); } - protected override firstUpdated() { - if (!this.linkInput) return; + override firstUpdated() { + this.disposables.addFromEvent(this, 'keydown', this._onKeydown); - this._disposables.addFromEvent(this.linkInput, 'copy', stopPropagation); - this._disposables.addFromEvent(this.linkInput, 'cut', stopPropagation); - this._disposables.addFromEvent(this.linkInput, 'paste', stopPropagation); + this.disposables.addFromEvent(this, 'copy', stopPropagation); + this.disposables.addFromEvent(this, 'cut', stopPropagation); + this.disposables.addFromEvent(this, 'paste', stopPropagation); + + this.disposables.addFromEvent(this.overlayMask, 'click', e => { + e.stopPropagation(); + this.std.host.selection.setGroup('note', []); + this.abortController.abort(); + }); } override render() { return html`
- ${this.type === 'view' - ? nothing - : html` - - `} -