mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-23 07:40:46 +08:00
feat(editor): add toolbar registry extension (#9572)
### What's Changed! #### Added Manage various types of toolbars uniformly in one place. * `affine-toolbar-widget` * `ToolbarRegistryExtension` The toolbar currently supports and handles several scenarios: 1. Select blocks: `BlockSelection` 2. Select text: `TextSelection` or `NativeSelection` 3. Hover a link: `affine-link` and `affine-reference` #### Removed Remove redundant toolbar implementations. * `attachment` toolbar * `bookmark` toolbar * `embed` toolbar * `formatting` toolbar * `affine-link` toolbar * `affine-reference` toolbar ### How to migrate? Here is an example that can help us migrate some unrefactored toolbars: Check out the more detailed types of [`ToolbarModuleConfig`](c178debf2d/blocksuite/affine/shared/src/services/toolbar-service/config.ts). 1. Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](c178debf2d/blocksuite/affine/block-bookmark/src/configs/toolbar.ts) ```ts export const builtinToolbarConfig = { actions: [ { id: 'a.preview', content(ctx) { const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel); if (!model) return null; const { url } = model; return html`<affine-link-preview .url=${url}></affine-link-preview>`; }, }, { id: 'b.conversions', actions: [ { id: 'inline', label: 'Inline view', run(ctx) { }, }, { id: 'card', label: 'Card view', disabled: true, }, { id: 'embed', label: 'Embed view', disabled(ctx) { }, run(ctx) { }, }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'c.style', actions: [ { id: 'horizontal', label: 'Large horizontal style', }, { id: 'list', label: 'Small horizontal style', }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'a.clipboard', actions: [ { id: 'copy', label: 'Copy', icon: CopyIcon(), run(ctx) { }, }, { id: 'duplicate', label: 'Duplicate', icon: DuplicateIcon(), run(ctx) { }, }, ], }, { placement: ActionPlacement.More, id: 'b.refresh', label: 'Reload', icon: ResetIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'c.delete', label: 'Delete', icon: DeleteIcon(), variant: 'destructive', run(ctx) { }, }, ], } as const satisfies ToolbarModuleConfig; ``` 2. Add configuration extension to a block spec: [bookmark's spec](c178debf2d/blocksuite/affine/block-bookmark/src/bookmark-spec.ts) ```ts const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ ..., ToolbarModuleExtension({ id: BlockFlavourIdentifier(flavour), config: builtinToolbarConfig, }), ].flat(); ``` 3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways: Check out the [editor's config](c178debf2d/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts (L51C4-L54C8)) file. ```ts // Defines a toolbar configuration for the bookmark block type const customBookmarkToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:bookmark'), config: customBookmarkToolbarConfig, }), ``` 4. If we want to extend the global: ```ts // Defines a toolbar configuration const customWildcardToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:*'), config: customWildcardToolbarConfig, }), ``` Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
This commit is contained in:
@@ -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<Attachment
|
||||
|
||||
protected _isResizing = false;
|
||||
|
||||
protected _whenHover: HoverController | null = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
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<Attachment
|
||||
const embedView = this.embedView;
|
||||
|
||||
return html`
|
||||
<div
|
||||
${this._whenHover ? ref(this._whenHover.setReference) : nothing}
|
||||
class="affine-attachment-container"
|
||||
style=${this.containerStyleMap}
|
||||
>
|
||||
<div class="affine-attachment-container" style=${this.containerStyleMap}>
|
||||
${embedView
|
||||
? html`<div class="affine-attachment-embed-container">
|
||||
${embedView}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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<AttachmentToolbarMoreMenuContext>[] =
|
||||
[
|
||||
{
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<div class="label">
|
||||
<span style="text-transform: capitalize">${viewType}</span>
|
||||
view
|
||||
</div>
|
||||
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
viewActions,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${label}
|
||||
data-testid=${`link-to-${type}`}
|
||||
?data-selected=${type === viewType}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
// <editor-icon-button aria-label="Preview" .tooltip=${'Preview'}>
|
||||
// ${ViewIcon}
|
||||
// </editor-icon-button>
|
||||
// `,
|
||||
|
||||
readonly
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Rename"
|
||||
.tooltip=${'Rename'}
|
||||
@click=${() => {
|
||||
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}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
attachmentViewToggleMenu({
|
||||
block,
|
||||
callback: () => abortController.abort(),
|
||||
}),
|
||||
|
||||
readonly
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Download"
|
||||
.tooltip=${'Download'}
|
||||
@click=${() => block.download()}
|
||||
>
|
||||
${DownloadIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
readonly
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Caption"
|
||||
.tooltip=${'Caption'}
|
||||
@click=${() => block.captionEditor?.show()}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${moreMenuActions}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${styles}
|
||||
</style>
|
||||
<editor-toolbar class="affine-attachment-toolbar">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
@@ -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 = ({
|
||||
<style>
|
||||
${renameStyles}
|
||||
</style>
|
||||
<div
|
||||
class="affine-attachment-rename-overlay-mask"
|
||||
@click="${() => abortController.abort()}"
|
||||
></div>
|
||||
<div class="affine-attachment-rename-overlay-mask" @click="${abort}"></div>
|
||||
<div class="affine-attachment-rename-container">
|
||||
<div class="affine-attachment-rename-input-wrapper">
|
||||
<input
|
||||
|
||||
@@ -57,43 +57,6 @@ export const renameStyles = css`
|
||||
}
|
||||
`;
|
||||
|
||||
export const moreMenuStyles = css`
|
||||
.affine-attachment-options-more {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.affine-attachment-options-more-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
}
|
||||
|
||||
.affine-attachment-options-more-container > 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;
|
||||
|
||||
278
blocksuite/affine/block-attachment/src/configs/toolbar.ts
Normal file
278
blocksuite/affine/block-attachment/src/configs/toolbar.ts
Normal file
@@ -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 extends SelectionConstructor>(
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
};
|
||||
|
||||
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`
|
||||
<editor-icon-button
|
||||
aria-label="Rename"
|
||||
.tooltip="${'Rename'}"
|
||||
@click=${() => {
|
||||
cx.hide();
|
||||
|
||||
createLitPortal({
|
||||
template: RenameModal({
|
||||
model: component.model,
|
||||
editorHost: cx.host,
|
||||
abortController,
|
||||
}),
|
||||
computePosition: {
|
||||
referenceElement: component,
|
||||
placement: 'top-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
},
|
||||
abortController,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${EditIcon()}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
324
blocksuite/affine/block-bookmark/src/configs/toolbar.ts
Normal file
324
blocksuite/affine/block-bookmark/src/configs/toolbar.ts
Normal file
@@ -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`<affine-link-preview .url=${url}></affine-link-preview>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[1].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.style$=${model.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
404
blocksuite/affine/block-embed/src/configs/toolbar.ts
Normal file
404
blocksuite/affine/block-embed/src/configs/toolbar.ts
Normal file
@@ -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`<affine-link-preview .url=${url}></affine-link-preview>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.style$=${model.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ToolbarAction>(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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.style$=${model.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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`<affine-linked-doc-title
|
||||
.title=${originalTitle}
|
||||
.open=${(event: MouseEvent) => component.open({ event })}
|
||||
></affine-linked-doc-title>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[1].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.style$=${model.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
@@ -109,7 +109,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
|
||||
};
|
||||
|
||||
private readonly _selectBlock = () => {
|
||||
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<EmbedLinke
|
||||
convertToEmbed = () => {
|
||||
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<EmbedLinke
|
||||
index
|
||||
);
|
||||
|
||||
this.std.selection.setGroup('note', []);
|
||||
doc.deleteBlock(this.model);
|
||||
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
};
|
||||
|
||||
covertToInline = () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ToolbarAction>(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`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
|
||||
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ label, icon, run, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${ifDefined(
|
||||
typeof disabled === 'function' ? disabled(ctx) : disabled
|
||||
)}
|
||||
@click=${() => run?.(ctx)}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[2].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
@@ -291,15 +291,18 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
}
|
||||
const index = parent.children.indexOf(this.model);
|
||||
|
||||
doc.addBlock(
|
||||
const blockId = doc.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
{ caption, ...this.referenceInfo, ...aliasInfo },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
this.std.selection.setGroup('note', []);
|
||||
doc.deleteBlock(this.model);
|
||||
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
};
|
||||
|
||||
covertToInline = () => {
|
||||
@@ -469,7 +472,7 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
|
||||
}
|
||||
|
||||
private _selectBlock() {
|
||||
const selectionManager = this.host.selection;
|
||||
const selectionManager = this.std.selection;
|
||||
const blockSelection = selectionManager.create(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
|
||||
import { EmbedSyncedDocBlockSchema } 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 { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js';
|
||||
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension';
|
||||
import { builtinToolbarConfig } from './configs/toolbar';
|
||||
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service';
|
||||
|
||||
const flavour = EmbedSyncedDocBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:embed-synced-doc'),
|
||||
FlavourExtension(flavour),
|
||||
EmbedSyncedDocBlockService,
|
||||
BlockViewExtension('affine:embed-synced-doc', model => {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
33
blocksuite/affine/block-embed/src/types.ts
Normal file
33
blocksuite/affine/block-embed/src/types.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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",
|
||||
|
||||
374
blocksuite/affine/block-note/src/configs/toolbar.ts
Normal file
374
blocksuite/affine/block-note/src/configs/toolbar.ts
Normal file
@@ -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`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Conversions"
|
||||
.tooltip="${'Turn Into'}"
|
||||
>
|
||||
${conversion.icon} ${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
|
||||
item => item.name,
|
||||
({ flavour, type, name, icon }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${name}
|
||||
?data-selected=${conversion.name === name}
|
||||
@click=${() => update(flavour, type)}
|
||||
>
|
||||
${icon}<span class="label">${name}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const 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`
|
||||
<affine-highlight-dropdown-menu
|
||||
.updateHighlight=${updateHighlight}
|
||||
></affine-highlight-dropdown-menu>
|
||||
`;
|
||||
},
|
||||
} 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</card-style-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
this.viewToggleMenu,
|
||||
|
||||
// TODO(@fundon): should remove it when refactoring the element toolbar
|
||||
attachmentViewDropdownMenu(new ToolbarContext(this.std)),
|
||||
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Download"
|
||||
@@ -137,7 +132,7 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
].filter(button => button !== nothing && button),
|
||||
].filter(button => button !== null),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<EmbedCardToolbarContext>[] = [
|
||||
{
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<GenerateDocUrlService['generateDocUrl']>;
|
||||
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<boolean>).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<boolean>).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<EmbedCardToolbarContext>[] =
|
||||
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`
|
||||
<editor-menu-button
|
||||
class="card-style-select"
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Card style" .tooltip=${'Card style'}>
|
||||
${PaletteIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleCardStyleSelector}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, icon }) => html`
|
||||
<icon-button
|
||||
width="76px"
|
||||
height="76px"
|
||||
aria-label=${label}
|
||||
class=${classMap({
|
||||
selected: model.style === type,
|
||||
})}
|
||||
@click=${() => this._setEmbedCardStyle(type)}
|
||||
>
|
||||
${icon}
|
||||
<affine-tooltip .offset=${4}>${label}</affine-tooltip>
|
||||
</icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
${OpenIcon}${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.label,
|
||||
({ label, icon, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<div class="label">
|
||||
<span style="text-transform: capitalize">${this._viewType}</span>
|
||||
view
|
||||
</div>
|
||||
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
data-testid=${`link-to-${type}`}
|
||||
aria-label=${ifDefined(label)}
|
||||
?data-selected=${this._viewType === type}
|
||||
?disabled=${disabled || this._viewType === type}
|
||||
@click=${() => {
|
||||
action();
|
||||
this._trackViewSelected(type);
|
||||
this._hide();
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<a
|
||||
class="affine-link-preview"
|
||||
href=${model.url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>${getHostName(model.url)}</span>
|
||||
</a>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
// internal embed model
|
||||
isEmbedLinkedDocBlock(model) && model.title
|
||||
? html`
|
||||
<editor-icon-button
|
||||
class="doc-title"
|
||||
aria-label="Doc title"
|
||||
.hover=${false}
|
||||
.labelHeight=${'20px'}
|
||||
.tooltip=${this._originalDocTitle}
|
||||
@click=${this.focusBlock?.open}
|
||||
>
|
||||
<span class="label">${this._originalDocTitle}</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
isEmbedHtmlBlock(model)
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Copy link"
|
||||
data-testid="copy-link"
|
||||
.tooltip=${'Copy link'}
|
||||
@click=${this._copyUrl}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
data-testid="edit"
|
||||
.tooltip=${'Edit'}
|
||||
?disabled=${this.doc.readonly}
|
||||
@click=${this._openEditPopup}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
this._cardStyleSelector(),
|
||||
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Caption"
|
||||
.tooltip=${'Add Caption'}
|
||||
?disabled=${this.doc.readonly}
|
||||
@click=${this._showCaption}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${this._moreActions()}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<editor-toolbar class="embed-card-toolbar">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
|
||||
@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<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'embed card toolbar',
|
||||
type: `${viewType} view`,
|
||||
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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`
|
||||
<editor-icon-button
|
||||
data-testid=${item.id}
|
||||
?active=${item.isActive(
|
||||
formatBar.std.command.chain(),
|
||||
formatBar
|
||||
)}
|
||||
.tooltip=${item.name}
|
||||
@click=${() => {
|
||||
item.action(formatBar.std.command.chain(), formatBar);
|
||||
formatBar.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${typeof item.icon === 'function' ? item.icon() : item.icon}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
case 'custom': {
|
||||
template = item.render(formatBar);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
template = null;
|
||||
}
|
||||
|
||||
return [template, item] as const;
|
||||
})
|
||||
.filter(([template]) => template !== null && template !== undefined)
|
||||
// 1. delete the redundant dividers in the middle
|
||||
.filter(([_, item], index, list) => {
|
||||
if (
|
||||
item.type === 'divider' &&
|
||||
index + 1 < list.length &&
|
||||
list[index + 1][1].type === 'divider'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
// 2. delete the redundant dividers at the head and tail
|
||||
.filter(([_, item], index, list) => {
|
||||
if (item.type === 'divider') {
|
||||
if (index === 0) {
|
||||
return false;
|
||||
}
|
||||
if (index === list.length - 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([template]) => template)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
@@ -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`
|
||||
<editor-menu-content class="highlight-panel" data-show ${ref(containerRef)}>
|
||||
<div data-orientation="vertical">
|
||||
<!-- Text Color Highlight -->
|
||||
<div class="highligh-panel-heading">Color</div>
|
||||
${foregroundConfig.map(
|
||||
({ name, color }) => html`
|
||||
<editor-menu-action
|
||||
data-testid="${color ?? 'unset'}"
|
||||
@click="${() => {
|
||||
updateHighlight(formatBar.host, color, HighlightType.Color);
|
||||
formatBar.requestUpdate();
|
||||
}}"
|
||||
>
|
||||
<span style="display: flex; color: ${color}">
|
||||
${TextForegroundDuotoneIcon}
|
||||
</span>
|
||||
${name}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
|
||||
<!-- Text Background Highlight -->
|
||||
<div class="highligh-panel-heading">Background</div>
|
||||
${backgroundConfig.map(
|
||||
({ name, color }) => html`
|
||||
<editor-menu-action
|
||||
data-testid="${color ?? 'transparent'}"
|
||||
@click="${() => {
|
||||
updateHighlight(
|
||||
formatBar.host,
|
||||
color,
|
||||
HighlightType.Background
|
||||
);
|
||||
formatBar.requestUpdate();
|
||||
}}"
|
||||
>
|
||||
<span style="display: flex; color: ${color ?? 'transparent'}">
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</span>
|
||||
${name}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-content>
|
||||
`;
|
||||
};
|
||||
|
||||
export const HighlightButton = (formatBar: AffineFormatBarWidget) => {
|
||||
const editorHost = formatBar.host;
|
||||
|
||||
const { setFloating, setReference } = whenHover(isHover => {
|
||||
if (!isHover) {
|
||||
const panel =
|
||||
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const button =
|
||||
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-button');
|
||||
const panel =
|
||||
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
|
||||
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`
|
||||
<div class="highlight-button" ${ref(setReference)}>
|
||||
<editor-icon-button
|
||||
class="highlight-icon"
|
||||
data-last-used="${lastUsedColor ?? 'unset'}"
|
||||
@click="${() =>
|
||||
updateHighlight(editorHost, lastUsedColor, lastUsedHighlightType)}"
|
||||
>
|
||||
<span style="display: flex; color: ${lastUsedColor}">
|
||||
${HighLightDuotoneIcon}
|
||||
</span>
|
||||
${ArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
${highlightPanel}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -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`
|
||||
<editor-menu-action
|
||||
data-testid="${item.id}"
|
||||
@click="${() => item.action(formatBar.std.command.chain(), formatBar)}"
|
||||
>
|
||||
${typeof item.icon === 'function' ? item.icon() : item.icon}
|
||||
${item.name}
|
||||
</editor-menu-action>
|
||||
`
|
||||
);
|
||||
|
||||
return html`
|
||||
<editor-menu-content class="paragraph-panel" data-show ${ref(containerRef)}>
|
||||
<div data-orientation="vertical">${renderedConfig}</div>
|
||||
</editor-menu-content>
|
||||
`;
|
||||
};
|
||||
|
||||
export const ParagraphButton = (formatBar: AffineFormatBarWidget) => {
|
||||
if (formatBar.displayType !== 'text' && formatBar.displayType !== 'block') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedBlocks = formatBar.selectedBlocks;
|
||||
// only support model with text
|
||||
if (selectedBlocks.some(el => !el.model.text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paragraphIcon =
|
||||
selectedBlocks.length < 1
|
||||
? textConversionConfigs[0].icon
|
||||
: (textConversionConfigs.find(
|
||||
({ flavour, type }) =>
|
||||
selectedBlocks[0].flavour === flavour &&
|
||||
(selectedBlocks[0].model as ParagraphBlockModel).type === type
|
||||
)?.icon ?? textConversionConfigs[0].icon);
|
||||
|
||||
const rootComponent = formatBar.block;
|
||||
if (rootComponent.model.flavour !== 'affine:page') {
|
||||
console.error('paragraph button host is not a page component');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { setFloating, setReference } = whenHover(isHover => {
|
||||
if (!isHover) {
|
||||
const panel =
|
||||
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const formatQuickBarElement = formatBar.formatBarElement;
|
||||
const panel =
|
||||
formatBar.shadowRoot?.querySelector<HTMLElement>('.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`
|
||||
<div class="paragraph-button" ${ref(setReference)}>
|
||||
<editor-icon-button class="paragraph-button-icon">
|
||||
${paragraphIcon} ${ArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
${paragraphPanel}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -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<InitCommandCtx>,
|
||||
formatBar: AffineFormatBarWidget
|
||||
) => void;
|
||||
icon: TemplateResult | (() => HTMLElement);
|
||||
isActive: (
|
||||
chain: Chain<InitCommandCtx>,
|
||||
formatBar: AffineFormatBarWidget
|
||||
) => boolean;
|
||||
showWhen: (
|
||||
chain: Chain<InitCommandCtx>,
|
||||
formatBar: AffineFormatBarWidget
|
||||
) => boolean;
|
||||
};
|
||||
export type ParagraphActionConfigItem = {
|
||||
id: string;
|
||||
type: 'paragraph-action';
|
||||
name: string;
|
||||
action: (
|
||||
chain: Chain<InitCommandCtx>,
|
||||
formatBar: AffineFormatBarWidget
|
||||
) => void;
|
||||
icon: TemplateResult | (() => HTMLElement);
|
||||
flavour: string;
|
||||
};
|
||||
|
||||
export type CustomConfigItem = {
|
||||
type: 'custom';
|
||||
render: (formatBar: AffineFormatBarWidget) => TemplateResult | null;
|
||||
};
|
||||
|
||||
export type FormatBarConfigItem =
|
||||
| DividerConfigItem
|
||||
| HighlighterDropdownConfigItem
|
||||
| ParagraphDropdownConfigItem
|
||||
| ParagraphActionConfigItem
|
||||
| InlineActionConfigItem
|
||||
| CustomConfigItem;
|
||||
|
||||
export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) {
|
||||
toolbar
|
||||
.clearConfig()
|
||||
.addParagraphDropdown()
|
||||
.addDivider()
|
||||
.addTextStyleToggle({
|
||||
key: 'bold',
|
||||
action: chain => chain.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<FormatBarContext>[] = [
|
||||
{
|
||||
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`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button="${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}"
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">${actions}</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
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');
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<FormatBarContext>[] = cloneGroups(BUILT_IN_GROUPS);
|
||||
|
||||
private get _selectionManager() {
|
||||
return this.host.selection;
|
||||
}
|
||||
|
||||
get displayType() {
|
||||
return this._displayType;
|
||||
}
|
||||
|
||||
get nativeRange() {
|
||||
const sl = document.getSelection();
|
||||
if (!sl || sl.rangeCount === 0) return null;
|
||||
return sl.getRangeAt(0);
|
||||
}
|
||||
|
||||
get selectedBlocks() {
|
||||
return this._selectedBlocks;
|
||||
}
|
||||
|
||||
private _calculatePlacement() {
|
||||
const rootComponent = this.block;
|
||||
|
||||
this.handleEvent('dragStart', () => {
|
||||
this._dragging = true;
|
||||
});
|
||||
|
||||
this.handleEvent('dragEnd', () => {
|
||||
this._dragging = false;
|
||||
});
|
||||
|
||||
// calculate placement
|
||||
this.disposables.add(
|
||||
this.host.event.add('pointerUp', ctx => {
|
||||
let targetRect: DOMRect | null = null;
|
||||
if (this.displayType === 'text' || this.displayType === 'native') {
|
||||
const range = this.nativeRange;
|
||||
if (!range) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
targetRect = range.getBoundingClientRect();
|
||||
} else if (this.displayType === 'block') {
|
||||
const block = this._selectedBlocks[0];
|
||||
if (!block) return;
|
||||
targetRect = block.getBoundingClientRect();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top: editorHostTop, bottom: editorHostBottom } =
|
||||
this.host.getBoundingClientRect();
|
||||
const e = ctx.get('pointerState');
|
||||
if (editorHostBottom - targetRect.bottom < 50) {
|
||||
this._placement = 'top';
|
||||
} else if (targetRect.top - Math.max(editorHostTop, 0) < 50) {
|
||||
this._placement = 'bottom';
|
||||
} else if (e.raw.y < targetRect.top + targetRect.height / 2) {
|
||||
this._placement = 'top';
|
||||
} else {
|
||||
this._placement = 'bottom';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// listen to selection change
|
||||
this.disposables.add(
|
||||
this._selectionManager.slots.changed.on(() => {
|
||||
const update = async () => {
|
||||
const textSelection = rootComponent.selection.find(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<RichText>('rich-text');
|
||||
const inline = richText?.inlineEditor;
|
||||
if (!richText || !inline) {
|
||||
return false;
|
||||
}
|
||||
const range = inline.getInlineRange();
|
||||
if (!range || range.length > 1) {
|
||||
return false;
|
||||
}
|
||||
const deltas = inline.getDeltasByInlineRange(range);
|
||||
if (deltas.length > 2) {
|
||||
return false;
|
||||
}
|
||||
const delta = deltas?.[1]?.[0];
|
||||
if (!delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return inline.isEmbed(delta);
|
||||
};
|
||||
|
||||
if (isEmbed()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: refactor later that ai panel & format bar should not depend on each other
|
||||
// do not display if AI panel is open
|
||||
const rootBlockId = this.host.doc.root?.id;
|
||||
const aiPanel = rootBlockId
|
||||
? this.host.view.getWidget('affine-ai-panel-widget', rootBlockId)
|
||||
: null;
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
if (aiPanel && aiPanel?.state !== 'hidden') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
addBlockTypeSwitch(config: {
|
||||
flavour: 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<InlineActionConfigItem, 'type'>) {
|
||||
this.configItems.push({ ...config, type: 'inline-action' });
|
||||
return this;
|
||||
}
|
||||
|
||||
addParagraphAction(config: Omit<ParagraphActionConfigItem, 'type'>) {
|
||||
this.configItems.push({ ...config, type: 'paragraph-action' });
|
||||
return this;
|
||||
}
|
||||
|
||||
addParagraphDropdown() {
|
||||
this.configItems.push({ type: 'paragraph-dropdown' });
|
||||
return this;
|
||||
}
|
||||
|
||||
addRawConfigItems(configItems: FormatBarConfigItem[], index?: number) {
|
||||
if (index === undefined) {
|
||||
this.configItems.push(...configItems);
|
||||
} else {
|
||||
this.configItems.splice(index, 0, ...configItems);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
addTextStyleToggle(config: {
|
||||
icon: InlineActionConfigItem['icon'];
|
||||
key: Exclude<
|
||||
keyof AffineTextAttributes,
|
||||
'color' | 'background' | 'reference'
|
||||
>;
|
||||
action: InlineActionConfigItem['action'];
|
||||
}) {
|
||||
const { key } = config;
|
||||
return this.addInlineAction({
|
||||
id: key,
|
||||
name: camelCaseToWords(key),
|
||||
icon: config.icon,
|
||||
isActive: chain => {
|
||||
const [result] = chain.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`
|
||||
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
|
||||
${items}
|
||||
${moreButton
|
||||
? html`
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
${moreButton}
|
||||
`
|
||||
: nothing}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './config.js';
|
||||
export { AffineFormatBarWidget } from './format-bar.js';
|
||||
@@ -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}
|
||||
`;
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ColorScheme, Record<string, TemplateResult>> = {
|
||||
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<string> | ReadonlySignal<string>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggle: ((e: CustomEvent<boolean>) => 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`
|
||||
<editor-menu-button
|
||||
@toggle=${toggle}
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Card style"
|
||||
.tooltip="${'Card style'}"
|
||||
>
|
||||
${PaletteIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ id, label, icon, disabled, run }) => html`
|
||||
<editor-icon-button
|
||||
aria-label="${label}"
|
||||
data-testid="${id}"
|
||||
.tooltip="${label}"
|
||||
.activeMode="${'border'}"
|
||||
.iconContainerWidth="${'76px'}"
|
||||
.iconContainerHeight="${'76px'}"
|
||||
?active="${id === style}"
|
||||
?disabled="${ifDefined(disabled)}"
|
||||
@click=${() => run?.(context)}
|
||||
>
|
||||
${icon || icons[id]}
|
||||
</editor-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-card-style-dropdown-menu': CardStyleDropdownMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CardStyleDropdownMenu } from './dropdown-menu';
|
||||
|
||||
export * from './dropdown-menu';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'affine-card-style-dropdown-menu',
|
||||
CardStyleDropdownMenu
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="highlight" .tooltip="${'Highlight'}">
|
||||
<affine-highlight-duotone-icon
|
||||
style=${styleMap({
|
||||
'--color':
|
||||
// latestHighlightColor ?? 'var(--affine-text-primary-color)',
|
||||
'var(--affine-text-primary-color)',
|
||||
})}
|
||||
></affine-highlight-duotone-icon>
|
||||
|
||||
${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
<div class="highlight-heading">Color</div>
|
||||
${repeat(colors, color => {
|
||||
const isDefault = color === 'default';
|
||||
const value = isDefault
|
||||
? null
|
||||
: `var(${prefix}-foreground-${color})`;
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid="foreground-${color}"
|
||||
@click=${() => this._update(value, 'color')}
|
||||
>
|
||||
<affine-text-duotone-icon
|
||||
style=${styleMap({
|
||||
'--color': value ?? 'var(--affine-text-primary-color)',
|
||||
})}
|
||||
></affine-text-duotone-icon>
|
||||
<span class="label capitalize"
|
||||
>${isDefault ? `${color} color` : color}</span
|
||||
>
|
||||
</editor-menu-action>
|
||||
`;
|
||||
})}
|
||||
|
||||
<div class="highlight-heading">Background</div>
|
||||
${repeat(colors, color => {
|
||||
const isDefault = color === 'default';
|
||||
const value = isDefault ? null : `var(${prefix}-${color})`;
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid="background-${color}"
|
||||
@click=${() => this._update(value, 'background')}
|
||||
>
|
||||
<affine-text-duotone-icon
|
||||
style=${styleMap({
|
||||
'--color': 'var(--affine-text-primary-color)',
|
||||
'--background': value ?? 'transparent',
|
||||
})}
|
||||
></affine-text-duotone-icon>
|
||||
|
||||
<span class="label capitalize"
|
||||
>${isDefault ? `${color} background` : color}</span
|
||||
>
|
||||
</editor-menu-action>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-highlight-dropdown-menu': HighlightDropdownMenu;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
7
blocksuite/affine/components/src/link-preview/index.ts
Normal file
7
blocksuite/affine/components/src/link-preview/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LinkPreview } from './link';
|
||||
|
||||
export * from './link';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-link-preview', LinkPreview);
|
||||
}
|
||||
73
blocksuite/affine/components/src/link-preview/link.ts
Normal file
73
blocksuite/affine/components/src/link-preview/link.ts
Normal file
@@ -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`
|
||||
<a
|
||||
class="affine-link-preview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href=${url}
|
||||
>
|
||||
<span>${getHostName(url)}</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-link-preview': LinkPreview;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<editor-icon-button
|
||||
aria-label="Doc title"
|
||||
.hover=${false}
|
||||
.labelHeight="${'20px'}"
|
||||
.tooltip=${title}
|
||||
@click=${(event: MouseEvent) => open(event)}
|
||||
>
|
||||
<span class="label">${title}</span>
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-linked-doc-title': DocTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DocTitle } from './doc-title';
|
||||
|
||||
export * from './doc-title';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-linked-doc-title', DocTitle);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`<a
|
||||
${ref(this._whenHover.setReference)}
|
||||
href=${this.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
ActionPlacement,
|
||||
EmbedOptionProvider,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
UnlinkIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit-html';
|
||||
import { keyed } from 'lit-html/directives/keyed.js';
|
||||
|
||||
import { toast } from '../../../../../../toast';
|
||||
import { AffineLink } from '../affine-link';
|
||||
import { toggleLinkPopup } from '../link-popup/toggle-link-popup';
|
||||
|
||||
const trackBaseProps = {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'link',
|
||||
type: 'inline view',
|
||||
};
|
||||
|
||||
export const builtinInlineLinkToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.preview',
|
||||
content(cx) {
|
||||
const target = cx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineLink)) return null;
|
||||
|
||||
const { link } = target;
|
||||
|
||||
return html`<affine-link-preview .url=${link}></affine-link-preview>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
target,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
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<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
@@ -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<boolean>).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`
|
||||
<a
|
||||
class="affine-link-preview"
|
||||
href=${this.currentLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
@click=${(e: MouseEvent) => this.openLink?.(e)}
|
||||
>
|
||||
<span>${getHostName(this.currentLink)}</span>
|
||||
</a>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Copy"
|
||||
data-testid="copy-link"
|
||||
.tooltip=${'Copy link'}
|
||||
@click=${this._copyUrl}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
data-testid="edit"
|
||||
.tooltip=${'Edit'}
|
||||
@click=${this._edit}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${this._moreActions()}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<editor-toolbar class="affine-link-popover view">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
};
|
||||
|
||||
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<BlockComponent>(`[${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`
|
||||
<editor-icon-button
|
||||
class="affine-confirm-button"
|
||||
.iconSize=${'24px'}
|
||||
.iconSize="${'24px'}"
|
||||
.disabled=${true}
|
||||
@click=${this._onConfirm}
|
||||
>
|
||||
${ConfirmIcon}
|
||||
${DoneIcon()}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
.iconSize=${'16px'}
|
||||
>
|
||||
<div class="label">Inline view</div>
|
||||
${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action }) => html`
|
||||
<editor-menu-action
|
||||
data-testid=${`link-to-${type}`}
|
||||
?data-selected=${type === 'inline'}
|
||||
?disabled=${type === 'inline'}
|
||||
@click=${() => {
|
||||
action?.();
|
||||
this._trackViewSelected(type);
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="overlay-root">
|
||||
${this.type === 'view'
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="affine-link-popover-overlay-mask"
|
||||
@click=${() => {
|
||||
this.abortController.abort();
|
||||
this.host?.selection.clear();
|
||||
}}
|
||||
></div>
|
||||
`}
|
||||
<div class="affine-link-popover-container" @keydown=${this._onKeydown}>
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="popover-container">
|
||||
${choose(this.type, [
|
||||
['create', this._createTemplate],
|
||||
['edit', this._editTemplate],
|
||||
['view', this._viewTemplate],
|
||||
])}
|
||||
</div>
|
||||
<div class="mock-selection-container"></div>
|
||||
@@ -607,29 +232,29 @@ export class LinkPopup extends WithDisposable(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.type !== 'view') {
|
||||
const domRects = range.getClientRects();
|
||||
const domRects = range.getClientRects();
|
||||
|
||||
Object.values(domRects).forEach(domRect => {
|
||||
if (!this.mockSelectionContainer) {
|
||||
return;
|
||||
}
|
||||
const mockSelection = document.createElement('div');
|
||||
mockSelection.classList.add('mock-selection');
|
||||
mockSelection.style.left = `${domRect.left}px`;
|
||||
mockSelection.style.top = `${domRect.top}px`;
|
||||
mockSelection.style.width = `${domRect.width}px`;
|
||||
mockSelection.style.height = `${domRect.height}px`;
|
||||
Object.values(domRects).forEach(domRect => {
|
||||
if (!this.mockSelectionContainer) {
|
||||
return;
|
||||
}
|
||||
const mockSelection = document.createElement('div');
|
||||
mockSelection.classList.add('mock-selection');
|
||||
mockSelection.style.left = `${domRect.left}px`;
|
||||
mockSelection.style.top = `${domRect.top}px`;
|
||||
mockSelection.style.width = `${domRect.width}px`;
|
||||
mockSelection.style.height = `${domRect.height}px`;
|
||||
|
||||
this.mockSelectionContainer.append(mockSelection);
|
||||
});
|
||||
}
|
||||
this.mockSelectionContainer.append(mockSelection);
|
||||
});
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
const popover = this.popoverContainer;
|
||||
|
||||
computePosition(visualElement, popover, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
@@ -639,10 +264,8 @@ export class LinkPopup extends WithDisposable(LitElement) {
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
popover.style.left = `${x}px`;
|
||||
popover.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
@@ -662,11 +285,11 @@ export class LinkPopup extends WithDisposable(LitElement) {
|
||||
@query('.mock-selection-container')
|
||||
accessor mockSelectionContainer!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor openLink: ((e?: MouseEvent) => void) | null = null;
|
||||
@query('.overlay-mask')
|
||||
accessor overlayMask!: HTMLDivElement;
|
||||
|
||||
@query('.affine-link-popover-container')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
@query('.popover-container')
|
||||
accessor popoverContainer!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor targetInlineRange!: InlineRange;
|
||||
@@ -675,20 +298,8 @@ export class LinkPopup extends WithDisposable(LitElement) {
|
||||
accessor textInput: HTMLInputElement | null = null;
|
||||
|
||||
@property()
|
||||
accessor type: 'create' | 'edit' | 'view' = 'create';
|
||||
}
|
||||
accessor type: 'create' | 'edit' = 'create';
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'link toolbar',
|
||||
type: 'inline view',
|
||||
category: 'link',
|
||||
...props,
|
||||
});
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export const linkPopupStyle = css`
|
||||
background-color: rgba(35, 131, 226, 0.28);
|
||||
}
|
||||
|
||||
.affine-link-popover-container {
|
||||
.popover-container {
|
||||
z-index: var(--affine-z-index-popover);
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
position: absolute;
|
||||
@@ -116,7 +116,7 @@ export const linkPopupStyle = css`
|
||||
}
|
||||
}
|
||||
|
||||
.affine-link-popover-overlay-mask {
|
||||
.overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -125,39 +125,6 @@ export const linkPopupStyle = css`
|
||||
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);
|
||||
}
|
||||
|
||||
.affine-link-popover.create {
|
||||
${PANEL_BASE};
|
||||
gap: 12px;
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
|
||||
import { LinkPopup } from './link-popup.js';
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
import { LinkPopup } from './link-popup';
|
||||
|
||||
export function toggleLinkPopup(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
std: BlockStdScope,
|
||||
type: LinkPopup['type'],
|
||||
inlineEditor: AffineInlineEditor,
|
||||
targetInlineRange: InlineRange,
|
||||
abortController: AbortController,
|
||||
openLink: ((e?: MouseEvent) => void) | null = null
|
||||
abortController: AbortController
|
||||
): LinkPopup {
|
||||
const popup = new LinkPopup();
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.std = std;
|
||||
popup.type = type;
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.targetInlineRange = targetInlineRange;
|
||||
popup.openLink = openLink;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isInsideBlockByFlavour,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit-html';
|
||||
import { keyed } from 'lit-html/directives/keyed.js';
|
||||
|
||||
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../../notification';
|
||||
import { AffineReference } from '../reference-node';
|
||||
|
||||
const trackBaseProps = {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
};
|
||||
|
||||
export const builtinInlineReferenceToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.doc-title',
|
||||
content(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return null;
|
||||
if (!target.referenceInfo.title) return null;
|
||||
|
||||
return html`<affine-linked-doc-title
|
||||
.title=${target.docTitle}
|
||||
.open=${(event: MouseEvent) => target.open({ event })}
|
||||
></affine-linked-doc-title>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 AffineReference)) return;
|
||||
if (!target.block) return;
|
||||
|
||||
const {
|
||||
block: { model },
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
referenceInfo,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = inlineEditor.yTextLength;
|
||||
const inlineTextLength = selfInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
ctx.store.deleteBlock(model);
|
||||
} else {
|
||||
inlineEditor.insertText(selfInlineRange, target.docTitle);
|
||||
}
|
||||
|
||||
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 target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return true;
|
||||
if (!target.block) return true;
|
||||
|
||||
if (
|
||||
isInsideBlockByFlavour(
|
||||
ctx.store,
|
||||
target.block.model,
|
||||
'affine:edgeless-text'
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
// nesting is not supported
|
||||
if (target.closest('affine-embed-synced-doc-block')) return true;
|
||||
|
||||
// same doc
|
||||
if (target.referenceInfo.pageId === ctx.store.id) return true;
|
||||
|
||||
// linking to block
|
||||
if (target.referenceToNode()) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
if (!target.block) return;
|
||||
|
||||
const {
|
||||
block: { model },
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
cloneReferenceInfoWithoutAliases(referenceInfo),
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = inlineEditor.yTextLength;
|
||||
const inlineTextLength = selfInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
ctx.store.deleteBlock(model);
|
||||
} else {
|
||||
inlineEditor.insertText(selfInlineRange, target.docTitle);
|
||||
}
|
||||
|
||||
const hasTitleAlias = Boolean(referenceInfo.title);
|
||||
|
||||
if (hasTitleAlias) {
|
||||
notifyLinkedDocSwitchedToEmbed(ctx.std);
|
||||
}
|
||||
|
||||
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 AffineReference)) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal(actions[0].label);
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
target,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
when(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) 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;
|
||||
|
||||
return true;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
|
||||
const { inlineEditor, selfInlineRange } = target;
|
||||
if (!inlineEditor || !selfInlineRange) return;
|
||||
|
||||
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
|
||||
|
||||
inlineEditor.deleteText(selfInlineRange);
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './reference-config';
|
||||
export { AffineReference } from './reference-node';
|
||||
export { toggleReferencePopup } from './reference-popup/toggle-reference-popup';
|
||||
export type { DocLinkClickedEvent, RefNodeSlots } from './types';
|
||||
@@ -3,7 +3,7 @@ import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { AffineReference } from './reference-node.js';
|
||||
import type { AffineReference } from './reference-node';
|
||||
|
||||
export interface ReferenceNodeConfig {
|
||||
customContent?: (reference: AffineReference) => TemplateResult;
|
||||
|
||||
@@ -3,20 +3,17 @@ import {
|
||||
DEFAULT_DOC_NAME,
|
||||
REFERENCE_NODE,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
cloneReferenceInfo,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
|
||||
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
@@ -31,15 +28,14 @@ import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { HoverController } from '../../../../../hover/index.js';
|
||||
import { Peekable } from '../../../../../peek/index.js';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
|
||||
import { affineTextStyles } from '../affine-text.js';
|
||||
import type { ReferenceNodeConfigProvider } from './reference-config.js';
|
||||
import { toggleReferencePopup } from './reference-popup.js';
|
||||
import { whenHover } from '../../../../../hover/index';
|
||||
import { Peekable } from '../../../../../peek/index';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index';
|
||||
import { affineTextStyles } from '../affine-text';
|
||||
import type { ReferenceNodeConfigProvider } from './reference-config';
|
||||
import type { DocLinkClickedEvent } from './types';
|
||||
|
||||
@Peekable({ action: false })
|
||||
export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
@@ -73,6 +69,10 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
}
|
||||
`;
|
||||
|
||||
get docTitle() {
|
||||
return this.refMeta?.title ?? DEFAULT_DOC_NAME;
|
||||
}
|
||||
|
||||
private readonly _updateRefMeta = (doc: Store) => {
|
||||
const refAttribute = this.delta.attributes?.reference;
|
||||
if (!refAttribute) {
|
||||
@@ -93,48 +93,6 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
@state()
|
||||
accessor refMeta: DocMeta | undefined = undefined;
|
||||
|
||||
private readonly _whenHover: HoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
if (
|
||||
this.config.hidePopup ||
|
||||
this.doc?.readonly ||
|
||||
this.closest('.prevent-reference-popup') ||
|
||||
!this.selfInlineRange ||
|
||||
!this.inlineEditor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = this.std.selection;
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (!!textSelection && !textSelection.isCollapsed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
if (blockSelections.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: toggleReferencePopup(
|
||||
this,
|
||||
this.referenceToNode(),
|
||||
this.referenceInfo,
|
||||
this.inlineEditor,
|
||||
this.selfInlineRange,
|
||||
this.refMeta?.title ?? DEFAULT_DOC_NAME,
|
||||
abortController
|
||||
),
|
||||
};
|
||||
},
|
||||
{ enterDelay: 500 }
|
||||
);
|
||||
|
||||
get _icon() {
|
||||
const { pageId, params, title } = this.referenceInfo;
|
||||
return this.std
|
||||
@@ -187,17 +145,52 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
return selfInlineRange;
|
||||
}
|
||||
|
||||
private _onClick() {
|
||||
readonly open = (event?: Partial<DocLinkClickedEvent>) => {
|
||||
if (!this.config.interactable) return;
|
||||
|
||||
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({
|
||||
...this.referenceInfo,
|
||||
...event,
|
||||
host: this.std.host,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_whenHover = whenHover(
|
||||
hovered => {
|
||||
if (!this.config.interactable) return;
|
||||
|
||||
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
|
||||
|
||||
if (hovered) {
|
||||
message$.value = {
|
||||
flavour: 'affine:reference',
|
||||
element: this,
|
||||
setFloating: this._whenHover.setFloating,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
console.error('`reference-node` need `ReferenceNodeConfig`.');
|
||||
return;
|
||||
@@ -281,11 +274,10 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
// we need to add `<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text>` in an
|
||||
// embed element to make sure inline range calculation is correct
|
||||
return html`<span
|
||||
${this.config.interactable ? ref(this._whenHover.setReference) : ''}
|
||||
data-selected=${this.selected}
|
||||
class="affine-reference"
|
||||
style=${styleMap(style)}
|
||||
@click=${this._onClick}
|
||||
@click=${(event: MouseEvent) => this.open({ event })}
|
||||
>${content}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import {
|
||||
GenerateDocUrlProvider,
|
||||
type LinkEventType,
|
||||
OpenDocExtensionIdentifier,
|
||||
type OpenDocMode,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isInsideBlockByFlavour,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
} from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
OpenIcon,
|
||||
} from '../../../../../icons/index.js';
|
||||
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../notification/index.js';
|
||||
import { isPeekable, peek } from '../../../../../peek/index.js';
|
||||
import { toast } from '../../../../../toast/toast.js';
|
||||
import {
|
||||
type MenuItem,
|
||||
renderActions,
|
||||
renderToolbarSeparator,
|
||||
} from '../../../../../toolbar/index.js';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
|
||||
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
|
||||
import { ReferenceAliasPopup } from './reference-alias-popup.js';
|
||||
import { styles } from './styles.js';
|
||||
import type { DocLinkClickedEvent } from './types.js';
|
||||
|
||||
export class ReferencePopup extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _copyLink = () => {
|
||||
if (!this.std) {
|
||||
console.error('`std` is not found');
|
||||
return;
|
||||
}
|
||||
const url = this.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(this.referenceInfo.pageId, this.referenceInfo.params);
|
||||
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(this.std.host, 'Copied link to clipboard');
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
|
||||
track(this.std, 'CopiedLink', { control: 'copy link' });
|
||||
};
|
||||
|
||||
private readonly _openDoc = (event?: Partial<DocLinkClickedEvent>) => {
|
||||
if (!this.std) {
|
||||
console.error('`std` is not found');
|
||||
return;
|
||||
}
|
||||
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({
|
||||
...this.referenceInfo,
|
||||
...event,
|
||||
host: this.std.host,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _openEditPopup = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (document.body.querySelector('reference-alias-popup')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
std,
|
||||
docTitle,
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
targetInlineRange,
|
||||
abortController,
|
||||
} = this;
|
||||
|
||||
if (!std) {
|
||||
console.error('`std` is not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const aliasPopup = new ReferenceAliasPopup();
|
||||
|
||||
aliasPopup.std = std;
|
||||
aliasPopup.docTitle = docTitle;
|
||||
aliasPopup.referenceInfo = referenceInfo;
|
||||
aliasPopup.inlineEditor = inlineEditor;
|
||||
aliasPopup.inlineRange = targetInlineRange;
|
||||
|
||||
document.body.append(aliasPopup);
|
||||
|
||||
abortController.abort();
|
||||
|
||||
track(std, 'OpenedAliasPopup', { control: 'edit' });
|
||||
};
|
||||
|
||||
private readonly _toggleViewSelector = (e: Event) => {
|
||||
if (!this.std) {
|
||||
console.error('`std` is not found');
|
||||
return;
|
||||
}
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.std, 'OpenedViewSelector', { control: 'switch view' });
|
||||
};
|
||||
|
||||
private readonly _trackViewSelected = (type: string) => {
|
||||
if (!this.std) {
|
||||
console.error('`std` is not found');
|
||||
return;
|
||||
}
|
||||
track(this.std, 'SelectedView', {
|
||||
control: 'select view',
|
||||
type: `${type} view`,
|
||||
});
|
||||
};
|
||||
|
||||
get _embedViewButtonDisabled() {
|
||||
if (!this.block) {
|
||||
console.error('`block` is not found');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this.block.doc.readonly ||
|
||||
isInsideBlockByFlavour(
|
||||
this.block.doc,
|
||||
this.block.model,
|
||||
'affine:edgeless-text'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!!this.block.closest('affine-embed-synced-doc-block') ||
|
||||
this.referenceDocId === this.block.doc.id
|
||||
);
|
||||
}
|
||||
|
||||
_openButtonDisabled(openMode?: OpenDocMode) {
|
||||
if (openMode === 'open-in-active-view') {
|
||||
return this.referenceDocId === this.doc?.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get block() {
|
||||
const block = this.inlineEditor.rootElement?.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
return block;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
const doc = this.block?.doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
get referenceDocId() {
|
||||
const docId = this.inlineEditor.getFormat(this.targetInlineRange).reference
|
||||
?.pageId;
|
||||
return docId;
|
||||
}
|
||||
|
||||
get std() {
|
||||
const std = this.block?.std;
|
||||
return std;
|
||||
}
|
||||
|
||||
private _convertToCardView() {
|
||||
const block = this.block;
|
||||
if (!block) return;
|
||||
|
||||
const doc = block.host.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(block.model);
|
||||
|
||||
doc.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
this.referenceInfo,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _convertToEmbedView() {
|
||||
const block = this.block;
|
||||
const std = block?.std;
|
||||
if (!std || !block) {
|
||||
console.error('`std` or `block` is not found');
|
||||
return;
|
||||
}
|
||||
const doc = block.host.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
if (!parent) {
|
||||
console.error('`parent` is not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const index = parent.children.indexOf(block.model);
|
||||
const referenceInfo = this.referenceInfo;
|
||||
const hasTitleAlias = Boolean(referenceInfo.title);
|
||||
|
||||
doc.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
cloneReferenceInfoWithoutAliases(referenceInfo),
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
|
||||
}
|
||||
|
||||
if (hasTitleAlias) {
|
||||
notifyLinkedDocSwitchedToEmbed(std);
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _delete() {
|
||||
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
|
||||
this.inlineEditor.deleteText(this.targetInlineRange);
|
||||
}
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _moreActions() {
|
||||
return renderActions([
|
||||
[
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
disabled: this.doc?.readonly,
|
||||
action: () => this._delete(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private _openMenuButton() {
|
||||
if (!this.std) {
|
||||
console.error('`std` is not found');
|
||||
return nothing;
|
||||
}
|
||||
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
|
||||
|
||||
const buttons: MenuItem[] = openDocConfig.items
|
||||
.map(item => {
|
||||
if (
|
||||
(item.type === 'open-in-center-peek' && !isPeekable(this.target)) ||
|
||||
!openDocConfig?.isAllowed(item.type)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
icon: item.icon,
|
||||
action: () => {
|
||||
if (item.type === 'open-in-center-peek') {
|
||||
peek(this.target);
|
||||
} else {
|
||||
this._openDoc({ openMode: item.type });
|
||||
}
|
||||
},
|
||||
disabled: this._openButtonDisabled(item.type),
|
||||
when: () => {
|
||||
if (item.type === 'open-in-center-peek') {
|
||||
return isPeekable(this.target);
|
||||
}
|
||||
return openDocConfig?.isAllowed(item.type) ?? true;
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(item => item !== null);
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open doc"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
${OpenIcon} ${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.label,
|
||||
({ label, icon, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _viewSelector() {
|
||||
const buttons = [];
|
||||
|
||||
buttons.push({
|
||||
type: 'inline',
|
||||
label: 'Inline view',
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
type: 'card',
|
||||
label: 'Card view',
|
||||
action: () => this._convertToCardView(),
|
||||
disabled: this.doc?.readonly,
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
type: 'embed',
|
||||
label: 'Embed view',
|
||||
action: () => this._convertToEmbedView(),
|
||||
disabled:
|
||||
this.doc?.readonly ||
|
||||
this.isLinkedNode ||
|
||||
this._embedViewButtonDisabled,
|
||||
});
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<span class="label">Inline view</span>
|
||||
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${label}
|
||||
data-testid=${`link-to-${type}`}
|
||||
?data-selected=${type === 'inline'}
|
||||
?disabled=${disabled || type === 'inline'}
|
||||
@click=${() => {
|
||||
action?.();
|
||||
this._trackViewSelected(type);
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.targetInlineRange.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.block) return;
|
||||
|
||||
const parent = this.block.host.doc.getParent(this.block.model);
|
||||
if (!parent) return;
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
if (!this.block) return;
|
||||
const children = parent.children;
|
||||
if (children.includes(this.block.model)) return;
|
||||
this.abortController.abort();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const titleButton = this.referenceInfo.title
|
||||
? html`
|
||||
<editor-icon-button
|
||||
class="doc-title"
|
||||
aria-label="Doc title"
|
||||
.hover=${false}
|
||||
.labelHeight=${'20px'}
|
||||
.tooltip=${this.docTitle}
|
||||
@click=${this._openDoc}
|
||||
>
|
||||
<span class="label">${this.docTitle}</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing;
|
||||
|
||||
const buttons = [
|
||||
this._openMenuButton(),
|
||||
|
||||
html`
|
||||
${titleButton}
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Copy link"
|
||||
data-testid="copy-link"
|
||||
.tooltip=${'Copy link'}
|
||||
@click=${this._copyLink}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
data-testid="edit"
|
||||
.tooltip=${'Edit'}
|
||||
?disabled=${this.doc?.readonly}
|
||||
@click=${this._openEditPopup}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${this._moreActions()}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
<div class="affine-reference-popover-container">
|
||||
<editor-toolbar class="affine-reference-popover view">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
|
||||
if (!range) return;
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docTitle!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: AffineInlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isLinkedNode!: boolean;
|
||||
|
||||
@query('.affine-reference-popover-container')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor referenceInfo!: ReferenceInfo;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor target!: LitElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor targetInlineRange!: InlineRange;
|
||||
}
|
||||
|
||||
export function toggleReferencePopup(
|
||||
target: LitElement,
|
||||
isLinkedNode: boolean,
|
||||
referenceInfo: ReferenceInfo,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
targetInlineRange: InlineRange,
|
||||
docTitle: string,
|
||||
abortController: AbortController
|
||||
): ReferencePopup {
|
||||
const popup = new ReferencePopup();
|
||||
popup.target = target;
|
||||
popup.isLinkedNode = isLinkedNode;
|
||||
popup.referenceInfo = referenceInfo;
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.targetInlineRange = targetInlineRange;
|
||||
popup.docTitle = docTitle;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'reference toolbar',
|
||||
type: 'inline view',
|
||||
category: 'linked doc',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
@@ -7,20 +7,21 @@ import {
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
|
||||
import type { DeltaInsert, InlineRange } from '@blocksuite/inline';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
|
||||
import type { EditorIconButton } from '../../../../../toolbar/index.js';
|
||||
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
|
||||
import type { EditorIconButton } from '../../../../../../toolbar/index';
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
|
||||
export class ReferenceAliasPopup extends SignalWatcher(
|
||||
export class ReferencePopup extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
@@ -37,7 +38,7 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.alias-form-popup {
|
||||
.popover-container {
|
||||
${PANEL_BASE};
|
||||
position: absolute;
|
||||
display: flex;
|
||||
@@ -159,11 +160,16 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
|
||||
|
||||
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.remove();
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
|
||||
|
||||
this.inputElement.focus();
|
||||
this.inputElement.select();
|
||||
@@ -173,7 +179,7 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="alias-form-popup">
|
||||
<div class="popover-container">
|
||||
<input
|
||||
id="alias-title"
|
||||
type="text"
|
||||
@@ -207,15 +213,15 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.inlineRange);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
if (!range) return;
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
const popover = this.popoverContainer;
|
||||
|
||||
computePosition(visualElement, popover, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
@@ -225,16 +231,14 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
popover.style.left = `${x}px`;
|
||||
popover.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta!: DeltaInsert<AffineTextAttributes>;
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docTitle!: string;
|
||||
@@ -251,8 +255,8 @@ export class ReferenceAliasPopup extends SignalWatcher(
|
||||
@query('.overlay-mask')
|
||||
accessor overlayMask!: HTMLDivElement;
|
||||
|
||||
@query('.alias-form-popup')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
@query('.popover-container')
|
||||
accessor popoverContainer!: HTMLDivElement;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor referenceInfo!: ReferenceInfo;
|
||||
@@ -272,11 +276,11 @@ function track(
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'reference edit popup',
|
||||
type: 'inline view',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
import { ReferencePopup } from './reference-popup';
|
||||
|
||||
export function toggleReferencePopup(
|
||||
std: BlockStdScope,
|
||||
docTitle: string,
|
||||
referenceInfo: ReferenceInfo,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
inlineRange: InlineRange,
|
||||
abortController: AbortController
|
||||
): ReferencePopup {
|
||||
const popup = new ReferencePopup();
|
||||
popup.std = std;
|
||||
popup.docTitle = docTitle;
|
||||
popup.referenceInfo = referenceInfo;
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.inlineRange = inlineRange;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-reference-popover-container {
|
||||
z-index: var(--affine-z-index-popover);
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes affine-popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
@@ -25,6 +25,7 @@ export class EditorIconButton extends LitElement {
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
width: var(--icon-container-width, unset);
|
||||
height: var(--icon-container-height, unset);
|
||||
justify-content: var(--justify, unset);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -33,6 +34,10 @@ export class EditorIconButton extends LitElement {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
:host([active]) .icon-container.active-mode-border {
|
||||
border: 1px solid var(--affine-brand-color);
|
||||
}
|
||||
|
||||
:host([active]) .icon-container.active-mode-background {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
@@ -44,8 +49,7 @@ export class EditorIconButton extends LitElement {
|
||||
|
||||
::slotted(svg) {
|
||||
flex-shrink: 0;
|
||||
width: var(--icon-size, unset);
|
||||
height: var(--icon-size, unset);
|
||||
font-size: var(--icon-size, 20px);
|
||||
}
|
||||
|
||||
::slotted(.label) {
|
||||
@@ -116,6 +120,7 @@ export class EditorIconButton extends LitElement {
|
||||
const padding = this.iconContainerPadding;
|
||||
const iconContainerStyles = styleMap({
|
||||
'--icon-container-width': this.iconContainerWidth,
|
||||
'--icon-container-height': this.iconContainerHeight,
|
||||
'--icon-container-padding': Array.isArray(padding)
|
||||
? padding.map(v => `${v}px`).join(' ')
|
||||
: `${padding}px`,
|
||||
@@ -156,7 +161,7 @@ export class EditorIconButton extends LitElement {
|
||||
accessor active = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor activeMode: 'color' | 'background' = 'color';
|
||||
accessor activeMode: 'color' | 'border' | 'background' = 'color';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor arrow = true;
|
||||
@@ -179,6 +184,9 @@ export class EditorIconButton extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor iconContainerWidth: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor iconContainerHeight: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor iconSize: string | undefined = undefined;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
|
||||
{
|
||||
mainAxis: 12,
|
||||
ignoreShift: true,
|
||||
offsetHeight: 6 * 4,
|
||||
}
|
||||
);
|
||||
this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
|
||||
@@ -54,9 +55,6 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
|
||||
});
|
||||
this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => {
|
||||
this._popper.toggle();
|
||||
if (this._popper.state === 'show') {
|
||||
this._content.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
this._disposables.add(this._popper);
|
||||
}
|
||||
@@ -91,7 +89,7 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
|
||||
private accessor _trigger!: EditorIconButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor button!: string | TemplateResult<1>;
|
||||
accessor button!: TemplateResult;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor contentPadding: string | undefined = undefined;
|
||||
@@ -104,6 +102,7 @@ export class EditorMenuContent extends LitElement {
|
||||
--offset-height: calc(-1 * var(--packed-height));
|
||||
display: none;
|
||||
outline: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
:host::before,
|
||||
@@ -154,6 +153,7 @@ export class EditorMenuContent extends LitElement {
|
||||
align-items: stretch;
|
||||
gap: unset;
|
||||
min-height: unset;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -206,6 +206,13 @@ export class EditorMenuAction extends LitElement {
|
||||
color: var(--affine-icon-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
::slotted(.label) {
|
||||
color: inherit !important;
|
||||
}
|
||||
::slotted(.label.capitalize) {
|
||||
text-transform: capitalize !important;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
|
||||
@@ -106,8 +106,10 @@ export function renderGroups<T>(groups: MenuItemGroup<T>[], context: T) {
|
||||
return renderActions(groupsToActions(groups, context));
|
||||
}
|
||||
|
||||
export function renderToolbarSeparator() {
|
||||
return html`<editor-toolbar-separator></editor-toolbar-separator>`;
|
||||
export function renderToolbarSeparator(orientation?: 'horizontal') {
|
||||
return html`<editor-toolbar-separator
|
||||
data-orientation=${ifDefined(orientation)}
|
||||
></editor-toolbar-separator>`;
|
||||
}
|
||||
|
||||
export function getMoreMenuConfig(std: BlockStdScope): ToolbarMoreMenuConfig {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
type ToolbarAction,
|
||||
ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
|
||||
import type { ReadonlySignal, Signal } from '@preact/signals-core';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { html } from 'lit-html';
|
||||
import { ifDefined } from 'lit-html/directives/if-defined.js';
|
||||
import { repeat } from 'lit-html/directives/repeat.js';
|
||||
|
||||
@requiredProperties({
|
||||
actions: PropTypes.array,
|
||||
context: PropTypes.instanceOf(ToolbarContext),
|
||||
viewType$: PropTypes.object,
|
||||
})
|
||||
export class ViewDropdownMenu extends SignalWatcher(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor actions!: ToolbarAction[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: ToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor viewType$!: Signal<string> | ReadonlySignal<string>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggle: ((e: CustomEvent<boolean>) => void) | undefined;
|
||||
|
||||
override render() {
|
||||
const {
|
||||
actions,
|
||||
context,
|
||||
toggle,
|
||||
viewType$: { value: viewType },
|
||||
} = this;
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
@toggle=${toggle}
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify="${'space-between'}"
|
||||
.labelHeight="${'20px'}"
|
||||
.iconContainerWidth="${'110px'}"
|
||||
>
|
||||
<span class="label">${viewType}</span>
|
||||
${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions.filter(action => {
|
||||
if (typeof action.when === 'function')
|
||||
return action.when(context);
|
||||
return action.when ?? true;
|
||||
}),
|
||||
action => action.id,
|
||||
({ id, label, disabled, run }) => html`
|
||||
<editor-menu-action
|
||||
aria-label="${label}"
|
||||
data-testid="${`link-to-${id}`}"
|
||||
?data-selected="${label === viewType}"
|
||||
?disabled="${ifDefined(
|
||||
typeof disabled === 'function' ? disabled(context) : disabled
|
||||
)}"
|
||||
@click=${() => run?.(context)}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-view-dropdown-menu': ViewDropdownMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ViewDropdownMenu } from './dropdown-menu';
|
||||
|
||||
export * from './dropdown-menu';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-view-dropdown-menu', ViewDropdownMenu);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type TableViewSelectionWithType,
|
||||
} from '../selection';
|
||||
import type { TableColumn } from '../table-view-manager.js';
|
||||
import type { TableGroup } from './group.js';
|
||||
|
||||
export class DatabaseCellContainer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
@@ -85,7 +86,7 @@ export class DatabaseCellContainer extends SignalWatcher(
|
||||
}
|
||||
|
||||
private get groupKey() {
|
||||
return this.closest('affine-data-view-table-group')?.group?.key;
|
||||
return this.closest<TableGroup>('affine-data-view-table-group')?.group?.key;
|
||||
}
|
||||
|
||||
private get readonly() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TableViewSelectionWithType,
|
||||
} from '../../selection';
|
||||
import type { DatabaseCellContainer } from '../cell.js';
|
||||
import type { TableGroup } from '../group.js';
|
||||
import type { TableRow } from '../row/row.js';
|
||||
import type { DataViewTable } from '../table-view.js';
|
||||
import {
|
||||
@@ -812,7 +813,8 @@ export class TableSelectionController implements ReactiveController {
|
||||
cell: DatabaseCellContainer,
|
||||
fillValues?: boolean
|
||||
) {
|
||||
const groupKey = cell.closest('affine-data-view-table-group')?.group?.key;
|
||||
const groupKey = cell.closest<TableGroup>('affine-data-view-table-group')
|
||||
?.group?.key;
|
||||
const table = this.tableContainer;
|
||||
const scrollContainer = table?.parentElement;
|
||||
if (!table || !scrollContainer) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type TableViewSelection,
|
||||
} from '../../selection';
|
||||
import type { TableSingleView } from '../../table-view-manager.js';
|
||||
import type { TableGroup } from '../group.js';
|
||||
import { openDetail, popRowMenu } from '../menu.js';
|
||||
|
||||
export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
|
||||
@@ -156,7 +157,7 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
|
||||
};
|
||||
|
||||
get groupKey() {
|
||||
return this.closest('affine-data-view-table-group')?.group?.key;
|
||||
return this.closest<TableGroup>('affine-data-view-table-group')?.group?.key;
|
||||
}
|
||||
|
||||
get selectionController() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { EmbedFigmaModel } from './figma';
|
||||
import { EmbedGithubModel } from './github';
|
||||
import type { EmbedHtmlModel } from './html';
|
||||
@@ -6,21 +8,31 @@ import { EmbedLoomModel } from './loom';
|
||||
import { EmbedSyncedDocModel } from './synced-doc';
|
||||
import { EmbedYoutubeModel } from './youtube';
|
||||
|
||||
export type ExternalEmbedModel =
|
||||
| EmbedFigmaModel
|
||||
| EmbedGithubModel
|
||||
| EmbedLoomModel
|
||||
| EmbedYoutubeModel;
|
||||
export const ExternalEmbedModels = [
|
||||
EmbedFigmaModel,
|
||||
EmbedGithubModel,
|
||||
EmbedLoomModel,
|
||||
EmbedYoutubeModel,
|
||||
] as const;
|
||||
|
||||
export type InternalEmbedModel = EmbedLinkedDocModel | EmbedSyncedDocModel;
|
||||
export const InternalEmbedModels = [
|
||||
EmbedLinkedDocModel,
|
||||
EmbedSyncedDocModel,
|
||||
] as const;
|
||||
|
||||
export type LinkableEmbedModel = ExternalEmbedModel | InternalEmbedModel;
|
||||
export type ExternalEmbedModel = (typeof ExternalEmbedModels)[number];
|
||||
|
||||
export type InternalEmbedModel = (typeof InternalEmbedModels)[number];
|
||||
|
||||
export type LinkableEmbedModel = InstanceType<
|
||||
ExternalEmbedModel | InternalEmbedModel
|
||||
>;
|
||||
|
||||
export type BuiltInEmbedModel = LinkableEmbedModel | EmbedHtmlModel;
|
||||
|
||||
export function isExternalEmbedModel(
|
||||
model: BuiltInEmbedModel
|
||||
): model is ExternalEmbedModel {
|
||||
model: BlockModel
|
||||
): model is InstanceType<ExternalEmbedModel> {
|
||||
return (
|
||||
model instanceof EmbedFigmaModel ||
|
||||
model instanceof EmbedGithubModel ||
|
||||
@@ -30,8 +42,8 @@ export function isExternalEmbedModel(
|
||||
}
|
||||
|
||||
export function isInternalEmbedModel(
|
||||
model: BuiltInEmbedModel
|
||||
): model is InternalEmbedModel {
|
||||
model: BlockModel
|
||||
): model is InstanceType<InternalEmbedModel> {
|
||||
return (
|
||||
model instanceof EmbedLinkedDocModel || model instanceof EmbedSyncedDocModel
|
||||
);
|
||||
|
||||
@@ -21,15 +21,13 @@ export const getBlockIndexCommand: Command<
|
||||
const parentModel = ctx.std.store.getParent(path);
|
||||
if (!parentModel) return;
|
||||
|
||||
const parent = ctx.std.view.getBlock(parentModel.id);
|
||||
if (!parent) return;
|
||||
const parentBlock = ctx.std.view.getBlock(parentModel.id);
|
||||
if (!parentBlock) return;
|
||||
|
||||
const index = parent.childBlocks.findIndex(x => {
|
||||
return x.blockId === path;
|
||||
});
|
||||
const blockIndex = parentBlock.childBlocks.findIndex(x => x.blockId === path);
|
||||
|
||||
next({
|
||||
blockIndex: index,
|
||||
parentBlock: parent as BlockComponent,
|
||||
blockIndex,
|
||||
parentBlock,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ export const DEFAULT_LINK_PREVIEW_ENDPOINT =
|
||||
export const CANVAS_EXPORT_IGNORE_TAGS = [
|
||||
'EDGELESS-TOOLBAR-WIDGET',
|
||||
'AFFINE-DRAG-HANDLE-WIDGET',
|
||||
'AFFINE-FORMAT-BAR-WIDGET',
|
||||
'AFFINE-TOOLBAR-WIDGET',
|
||||
'AFFINE-BLOCK-SELECTION',
|
||||
];
|
||||
|
||||
|
||||
@@ -19,5 +19,6 @@ export * from './quick-search-service';
|
||||
export * from './sidebar-service';
|
||||
export * from './telemetry-service';
|
||||
export * from './theme-service';
|
||||
export * from './toolbar-service';
|
||||
export * from './user-service';
|
||||
export * from './virtual-keyboard-service';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { ToolbarContext } from './context';
|
||||
|
||||
export enum ActionPlacement {
|
||||
Start = 0,
|
||||
End = 1 << 1,
|
||||
More = 1 << 2,
|
||||
}
|
||||
|
||||
type ActionBase = {
|
||||
id: string;
|
||||
score?: number;
|
||||
when?: ((cx: ToolbarContext) => boolean) | boolean;
|
||||
active?: ((cx: ToolbarContext) => boolean) | boolean;
|
||||
placement?: ActionPlacement;
|
||||
};
|
||||
|
||||
export type ToolbarAction = ActionBase & {
|
||||
label?: string;
|
||||
icon?: TemplateResult;
|
||||
tooltip?: string;
|
||||
variant?: 'destructive';
|
||||
disabled?: ((cx: ToolbarContext) => boolean) | boolean;
|
||||
content?:
|
||||
| ((cx: ToolbarContext) => TemplateResult | null)
|
||||
| (TemplateResult | null);
|
||||
run?: (cx: ToolbarContext) => void;
|
||||
};
|
||||
|
||||
// Generates an action at runtime
|
||||
export type ToolbarActionGenerator = ActionBase & {
|
||||
generate: (cx: ToolbarContext) => Omit<ToolbarAction, 'id'> | null;
|
||||
};
|
||||
|
||||
export type ToolbarActionGroup<
|
||||
T extends ActionBase = ToolbarAction | ToolbarActionGenerator,
|
||||
> = ActionBase & {
|
||||
actions: T[];
|
||||
content?:
|
||||
| ((cx: ToolbarContext) => TemplateResult | null)
|
||||
| (TemplateResult | null);
|
||||
};
|
||||
|
||||
// Generates an action group at runtime
|
||||
export type ToolbarActionGroupGenerator = ActionBase & {
|
||||
generate: (cx: ToolbarContext) => Omit<ToolbarActionGroup, 'id'> | null;
|
||||
};
|
||||
|
||||
export type ToolbarGenericAction =
|
||||
| ToolbarAction
|
||||
| ToolbarActionGenerator
|
||||
| ToolbarActionGroup
|
||||
| ToolbarActionGroupGenerator;
|
||||
|
||||
export type ToolbarActions<T extends ActionBase = ToolbarGenericAction> = T[];
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Placement } from '@floating-ui/dom';
|
||||
|
||||
import type { ToolbarActions } from './action';
|
||||
|
||||
export type ToolbarModuleConfig = {
|
||||
actions: ToolbarActions;
|
||||
|
||||
placement?: Extract<Placement, 'top' | 'top-start'>;
|
||||
};
|
||||
172
blocksuite/affine/shared/src/services/toolbar-service/context.ts
Normal file
172
blocksuite/affine/shared/src/services/toolbar-service/context.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
type BlockComponent,
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
} from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
BaseSelection,
|
||||
Block,
|
||||
SelectionConstructor,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import { matchModels } from '../../utils';
|
||||
import { DocModeProvider } from '../doc-mode-service';
|
||||
import { TelemetryProvider, type TelemetryService } from '../telemetry-service';
|
||||
import { ThemeProvider } from '../theme-service';
|
||||
import { ToolbarRegistryIdentifier } from './registry';
|
||||
|
||||
abstract class ToolbarContextBase {
|
||||
constructor(readonly std: BlockStdScope) {}
|
||||
|
||||
get command() {
|
||||
return this.std.command;
|
||||
}
|
||||
|
||||
get chain() {
|
||||
return this.command.chain();
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.store.doc;
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.std.workspace;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get clipboard() {
|
||||
return this.std.clipboard;
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.std.view;
|
||||
}
|
||||
|
||||
get activated() {
|
||||
if (this.readonly) return false;
|
||||
if (this.flags.accept()) return true;
|
||||
if (this.host.event.active) return true;
|
||||
// Selects `embed-synced-doc-block`
|
||||
return this.host.contains(document.activeElement);
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.store.readonly;
|
||||
}
|
||||
|
||||
get docModeProvider() {
|
||||
return this.std.get(DocModeProvider);
|
||||
}
|
||||
|
||||
get editorMode() {
|
||||
return this.docModeProvider.getEditorMode() ?? 'page';
|
||||
}
|
||||
|
||||
get isPageMode() {
|
||||
return this.editorMode === 'page';
|
||||
}
|
||||
|
||||
get isEdgelessMode() {
|
||||
return this.editorMode === 'edgeless';
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get themeProvider() {
|
||||
return this.std.get(ThemeProvider);
|
||||
}
|
||||
|
||||
get theme() {
|
||||
return this.themeProvider.theme;
|
||||
}
|
||||
|
||||
get toolbarRegistry() {
|
||||
return this.std.get(ToolbarRegistryIdentifier);
|
||||
}
|
||||
|
||||
get flags() {
|
||||
return this.toolbarRegistry.flags;
|
||||
}
|
||||
|
||||
get message$() {
|
||||
return this.toolbarRegistry.message$;
|
||||
}
|
||||
|
||||
getCurrentBlockBy<T extends SelectionConstructor>(type?: T): Block | null {
|
||||
const selection = this.selection.find(type ?? BlockSelection);
|
||||
return (selection && this.store.getBlock(selection.blockId)) ?? null;
|
||||
}
|
||||
|
||||
getCurrentModelBy<T extends SelectionConstructor>(type: T) {
|
||||
return this.getCurrentBlockBy<T>(type)?.model ?? null;
|
||||
}
|
||||
|
||||
getCurrentModelByType<
|
||||
T extends SelectionConstructor,
|
||||
M extends Parameters<typeof matchModels>[1][number],
|
||||
>(type: T, klass: M) {
|
||||
const model = this.getCurrentModelBy(type);
|
||||
return matchModels(model, [klass]) ? model : null;
|
||||
}
|
||||
|
||||
getCurrentBlockComponentBy<
|
||||
T extends SelectionConstructor,
|
||||
K extends abstract new (...args: any) => any,
|
||||
>(type: T, klass: K): InstanceType<K> | null {
|
||||
const block = this.getCurrentBlockBy<T>(type);
|
||||
const component = block && this.view.getBlock(block.id);
|
||||
return this.blockComponentIs(component, klass) ? component : null;
|
||||
}
|
||||
|
||||
blockComponentIs<K extends abstract new (...args: any) => any>(
|
||||
component: BlockComponent | null,
|
||||
...classes: K[]
|
||||
): component is InstanceType<K> {
|
||||
return classes.some(k => component instanceof k);
|
||||
}
|
||||
|
||||
select(group: string, selections: BaseSelection[] = []) {
|
||||
nextTick()
|
||||
.then(() => this.selection.setGroup(group, selections))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.flags.show();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.flags.hide();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.flags.reset();
|
||||
this.message$.value = null;
|
||||
}
|
||||
|
||||
get telemetryProvider() {
|
||||
return this.std.getOptional(TelemetryProvider);
|
||||
}
|
||||
|
||||
track = (...args: Parameters<TelemetryService['track']>) => {
|
||||
this.telemetryProvider?.track(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export class ToolbarContext extends ToolbarContextBase {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { batch, signal } from '@preact/signals-core';
|
||||
|
||||
export enum Flag {
|
||||
None = 0b0,
|
||||
Surface = 0b1,
|
||||
Block = 0b10,
|
||||
Text = 0b100,
|
||||
Native = 0b1000,
|
||||
// Hovering something, e.g. inline links
|
||||
Hovering = 0b10000,
|
||||
// Dragging something or opening modal, e.g. drag handle, drag resources from outside, bookmark rename modal
|
||||
Hiding = 0b100000,
|
||||
// When the editor is inactive and the toolbar is hidden, we still want to accept the message
|
||||
Accepting = 0b1000000,
|
||||
}
|
||||
|
||||
export class Flags {
|
||||
value$ = signal(Flag.None);
|
||||
|
||||
get value() {
|
||||
return this.value$.peek();
|
||||
}
|
||||
|
||||
toggle(flag: Flag, activated: boolean) {
|
||||
if (activated) {
|
||||
this.value$.value |= flag;
|
||||
return;
|
||||
}
|
||||
this.value$.value &= ~flag;
|
||||
}
|
||||
|
||||
check(flag: Flag, value = this.value) {
|
||||
return (flag & value) === flag;
|
||||
}
|
||||
|
||||
contains(flag: number, value = this.value) {
|
||||
return (flag & value) !== Flag.None;
|
||||
}
|
||||
|
||||
refresh(flag: Flag) {
|
||||
batch(() => {
|
||||
this.toggle(flag, false);
|
||||
this.toggle(flag, true);
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.value$.value = Flag.None;
|
||||
}
|
||||
|
||||
hide() {
|
||||
batch(() => {
|
||||
this.toggle(Flag.Accepting, true);
|
||||
this.toggle(Flag.Hiding, true);
|
||||
});
|
||||
}
|
||||
|
||||
show() {
|
||||
batch(() => {
|
||||
this.toggle(Flag.Hiding, false);
|
||||
this.toggle(Flag.Accepting, false);
|
||||
});
|
||||
}
|
||||
|
||||
isText() {
|
||||
return this.check(Flag.Text);
|
||||
}
|
||||
|
||||
isBlock() {
|
||||
return this.check(Flag.Block);
|
||||
}
|
||||
|
||||
isNative() {
|
||||
return this.check(Flag.Native);
|
||||
}
|
||||
|
||||
accept() {
|
||||
return this.check(Flag.Accepting);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user