Files
AFFiNE-Mirror/blocksuite/affine/block-attachment/src/attachment-block.ts
T
doouding 99717196c5 refactor: rewrite blocksuite dnd (#9595)
### Changed

- Refactored BlockSuite drag-and-drop using @atlaskit/pragmatic-drag-and-drop/element/adapter.
- Updated block dragging to use the new drag-and-drop infrastructure.

### BlockSuite DND API

Access the BlockSuite drag-and-drop API via `std.dnd`. This is a lightweight wrapper around pragmatic-drag-and-drop, offering convenient generic types and more intuitive option names.

#### Drag payload structure
There's some constrain about drag payload. The whole drag payload looks like this:

```typescript
type DragPayload = {
  entity: {
    type: string
  },
  from: {
    at: 'blocksuite',
    docId: string
  }
}
```
- The `from` field is auto-generated—no need for manual handling.
- The `entity` field is customizable, but it must include a `type`.

All drag-and-drop methods accept a generic type for entity, ensuring more accurate payloads in event handlers.

```typescript
type BlockEntity = {
  type: 'blocks',
  blockIds: string[]
}

dnd.draggable<BlockEntity>({
  element: someElement,
  setDragData: () => {
    // the return type must satisfy the generic type
    // in this case, it's BlockEntity
    return {
      type: 'blocks',
      blockIds: []
    }
  }
});

dnd.monitor<BlockEntity>({
  // the arguments is same for other event handler
  onDrag({ source }) {
    // the type of this is BlockEntity
    source.data.entity
  }
})
```

#### Drop payload
When hover on droppable target. You can set drop payload as well. All drag-and-drop methods accept a second generic type for drop payload.

The drop payload is customizable. Additionally, the DND system will add an `edge` field to the final payload object, indicating the nearest edge of the drop target relative to the current drag position.

```typescript
type DropPayload = {
  blockId: string;
}

dnd.dropTarget<BlockEntity, DropPayload>({
  getData() {
    // the type should be DropPayload
    return {
      blockId: 'someId'
    }
  }
});

dnd.monitor<BlockEntity, DropPayload>({
  // drag over on drop target
  onDrag({ location }) {
    const target = location.current.dropTargets[0];

    // the type is DropPayload
    target.data;
    // retrieve the nearest edge of the drop target relative to the current drop position.
    target.data.edge;
  }
})
```
2025-01-16 12:36:58 +00:00

305 lines
8.3 KiB
TypeScript

import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { HoverController } from '@blocksuite/affine-components/hover';
import {
AttachmentIcon16,
getAttachmentFileIcons,
} from '@blocksuite/affine-components/icons';
import { Peekable } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
type AttachmentBlockModel,
AttachmentBlockStyles,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
SurfaceSelection,
TextSelection,
} from '@blocksuite/block-std';
import { Slice } from '@blocksuite/store';
import { flip, offset } from '@floating-ui/dom';
import { html, nothing } 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 type { AttachmentBlockService } from './attachment-service.js';
import { AttachmentOptionsTemplate } from './components/options.js';
import { AttachmentEmbedProvider } from './embed.js';
import { styles } from './styles.js';
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js';
@Peekable()
export class AttachmentBlockComponent extends CaptionedBlockComponent<
AttachmentBlockModel,
AttachmentBlockService
> {
static override styles = styles;
protected _isDragging = false;
protected _isResizing = false;
protected _isSelected = 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({
position: 'relative',
width: '100%',
margin: '18px 0px',
});
convertTo = () => {
return this.std
.get(AttachmentEmbedProvider)
.convertTo(this.model, this.service.maxFileSize);
};
copy = () => {
const slice = Slice.fromModels(this.doc, [this.model]);
this.std.clipboard.copySlice(slice).catch(console.error);
toast(this.host, 'Copied to clipboard');
};
download = () => {
downloadAttachmentBlob(this);
};
embedded = () => {
return this.std
.get(AttachmentEmbedProvider)
.embedded(this.model, this.service.maxFileSize);
};
open = () => {
if (!this.blobUrl) {
return;
}
window.open(this.blobUrl, '_blank');
};
refreshData = () => {
checkAttachmentBlob(this).catch(console.error);
};
protected get embedView() {
return this.std
.get(AttachmentEmbedProvider)
.render(this.model, this.blobUrl, this.service.maxFileSize);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.refreshData();
this.contentEditable = 'false';
if (!this.model.style) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
style: AttachmentBlockStyles[1],
});
});
}
this.model.propsUpdated.on(({ key }) => {
if (key === 'sourceId') {
// Reset the blob url when the sourceId is changed
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = undefined;
}
this.refreshData();
}
});
// Workaround for https://github.com/toeverything/blocksuite/issues/4724
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.std.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is(BlockSelection) ||
!!this.selected?.is(SurfaceSelection);
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
}
override disconnectedCallback() {
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
}
super.disconnectedCallback();
}
override firstUpdated() {
// lazy bindings
this.disposables.addFromEvent(this, 'click', this.onClick);
}
protected onClick(event: MouseEvent) {
// the peek view need handle shift + click
if (event.defaultPrevented) return;
event.stopPropagation();
this._selectBlock();
}
override renderBlock() {
const { name, size, style } = this.model;
const cardStyle = style ?? AttachmentBlockStyles[1];
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon } = getEmbedCardIcons(theme);
const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16;
const titleText = this.loading ? 'Loading...' : name;
const infoText = this.error ? 'File loading failed.' : humanFileSize(size);
const fileType = name.split('.').pop() ?? '';
const FileTypeIcon = getAttachmentFileIcons(fileType);
const embedView = this.embedView;
return html`
<div
${this._whenHover ? ref(this._whenHover.setReference) : nothing}
class="affine-attachment-container"
style=${this.containerStyleMap}
>
${embedView
? html`<div class="affine-attachment-embed-container">
${embedView}
<div
class=${classMap({
'affine-attachment-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>`
: html`<div
class=${classMap({
'affine-attachment-card': true,
[cardStyle]: true,
loading: this.loading,
error: this.error,
unsynced: false,
})}
>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">
${titleIcon}
</div>
<div class="affine-attachment-content-title-text">
${titleText}
</div>
</div>
<div class="affine-attachment-content-info">${infoText}</div>
</div>
<div class="affine-attachment-banner">${FileTypeIcon}</div>
</div>`}
</div>
`;
}
@state()
protected accessor _showOverlay = true;
@property({ attribute: false })
accessor allowEmbed = false;
@property({ attribute: false })
accessor blobUrl: string | undefined = undefined;
@property({ attribute: false })
accessor downloading = false;
@property({ attribute: false })
accessor error = false;
@property({ attribute: false })
accessor loading = false;
override accessor useCaptionEditor = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-attachment': AttachmentBlockComponent;
}
}