mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
99717196c5
### 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;
}
})
```
305 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|