refactor(editor): extract attachment block (#9308)

This commit is contained in:
Saul-Mirone
2024-12-25 12:19:58 +00:00
parent d8bc145465
commit ebd97752bf
35 changed files with 272 additions and 125 deletions
@@ -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;
}
`;