mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
refactor(editor): extract attachment block (#9308)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
CaptionIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
MoreVerticalIcon,
|
||||
SmallArrowDownIcon,
|
||||
} 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/utils';
|
||||
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>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
viewActions,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
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'}>
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ConfirmIcon } from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { html } from 'lit';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { renameStyles } from './styles.js';
|
||||
|
||||
export const RenameModal = ({
|
||||
editorHost,
|
||||
model,
|
||||
abortController,
|
||||
}: {
|
||||
editorHost: EditorHost;
|
||||
model: AttachmentBlockModel;
|
||||
abortController: AbortController;
|
||||
}) => {
|
||||
const inputRef = createRef<HTMLInputElement>();
|
||||
// Fix auto focus
|
||||
setTimeout(() => inputRef.value?.focus());
|
||||
const originalName = model.name;
|
||||
const nameWithoutExtension = originalName.slice(
|
||||
0,
|
||||
originalName.lastIndexOf('.')
|
||||
);
|
||||
const originalExtension = originalName.slice(originalName.lastIndexOf('.'));
|
||||
const includeExtension =
|
||||
originalExtension.includes('.') &&
|
||||
originalExtension.length <= 7 &&
|
||||
// including the dot
|
||||
originalName.length > originalExtension.length;
|
||||
|
||||
let fileName = includeExtension ? nameWithoutExtension : originalName;
|
||||
const extension = includeExtension ? originalExtension : '';
|
||||
|
||||
const onConfirm = () => {
|
||||
const newFileName = fileName + extension;
|
||||
if (!newFileName) {
|
||||
toast(editorHost, 'File name cannot be empty');
|
||||
return;
|
||||
}
|
||||
model.doc.updateBlock(model, {
|
||||
name: newFileName,
|
||||
});
|
||||
abortController.abort();
|
||||
};
|
||||
const onInput = (e: InputEvent) => {
|
||||
fileName = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape' && !e.isComposing) {
|
||||
abortController.abort();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${renameStyles}
|
||||
</style>
|
||||
<div
|
||||
class="affine-attachment-rename-overlay-mask"
|
||||
@click="${() => abortController.abort()}"
|
||||
></div>
|
||||
<div class="affine-attachment-rename-container">
|
||||
<div class="affine-attachment-rename-input-wrapper">
|
||||
<input
|
||||
${ref(inputRef)}
|
||||
type="text"
|
||||
.value=${fileName}
|
||||
@input=${onInput}
|
||||
@keydown=${onKeydown}
|
||||
/>
|
||||
<span class="affine-attachment-rename-extension">${extension}</span>
|
||||
</div>
|
||||
<editor-icon-button
|
||||
class="affine-confirm-button"
|
||||
.iconSize=${'24px'}
|
||||
@click=${onConfirm}
|
||||
>
|
||||
${ConfirmIcon}
|
||||
</editor-icon-button>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const renameStyles = css`
|
||||
.affine-attachment-rename-container {
|
||||
${PANEL_BASE};
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper {
|
||||
display: flex;
|
||||
min-width: 280px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 10px;
|
||||
background: var(--affine-white-10);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper:focus-within {
|
||||
border-color: var(--affine-blue-700);
|
||||
box-shadow: var(--affine-active-shadow);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-extension {
|
||||
font-size: var(--affine-font-xs);
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user