diff --git a/packages/blocks/src/_common/adapters/html.ts b/packages/blocks/src/_common/adapters/html.ts index 7708ed2cc1d7..b1214382e3f4 100644 --- a/packages/blocks/src/_common/adapters/html.ts +++ b/packages/blocks/src/_common/adapters/html.ts @@ -131,19 +131,6 @@ export class HtmlAdapter extends BaseAdapter { ): Promise { const htmlAst = this._htmlToAst(payload.file); const titleAst = hastQuerySelector(htmlAst, 'title'); - const blockSnapshotRoot = { - type: 'block', - id: nanoid(), - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: '--affine-background-secondary-color', - index: 'a0', - hidden: false, - displayMode: NoteDisplayMode.DocAndEdgeless, - }, - children: [], - }; return { type: 'page', meta: { @@ -177,11 +164,7 @@ export class HtmlAdapter extends BaseAdapter { }, children: [], }, - await this._traverseHtml( - htmlAst, - blockSnapshotRoot as BlockSnapshot, - payload.assets - ), + await this._traverseHtml(htmlAst, payload.assets), ], }, }; @@ -190,45 +173,14 @@ export class HtmlAdapter extends BaseAdapter { payload: ToBlockSnapshotPayload ): Promise { const htmlAst = this._htmlToAst(payload.file); - const blockSnapshotRoot = { - type: 'block', - id: nanoid(), - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: '--affine-background-secondary-color', - index: 'a0', - hidden: false, - displayMode: NoteDisplayMode.DocAndEdgeless, - }, - children: [], - }; - return this._traverseHtml( - htmlAst, - blockSnapshotRoot as BlockSnapshot, - payload.assets - ); + return this._traverseHtml(htmlAst, payload.assets); } override async toSliceSnapshot( payload: HtmlToSliceSnapshotPayload ): Promise { const htmlAst = this._htmlToAst(payload.file); - const blockSnapshotRoot = { - type: 'block', - id: nanoid(), - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: '--affine-background-secondary-color', - index: 'a0', - hidden: false, - displayMode: NoteDisplayMode.DocAndEdgeless, - }, - children: [], - }; const contentSlice = (await this._traverseHtml( htmlAst, - blockSnapshotRoot as BlockSnapshot, payload.assets )) as BlockSnapshot; if (contentSlice.children.length === 0) { @@ -684,8 +636,20 @@ export class HtmlAdapter extends BaseAdapter { private _traverseHtml = async ( html: HtmlAST, - snapshot: BlockSnapshot, - assets?: AssetsManager + assets?: AssetsManager, + snapshot: BlockSnapshot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: '--affine-background-secondary-color', + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + } ) => { const walker = new ASTWalker(); walker.setONodeTypeGuard( diff --git a/packages/blocks/src/_common/adapters/index.ts b/packages/blocks/src/_common/adapters/index.ts index e72fd605ce8f..ed84c5d3b14f 100644 --- a/packages/blocks/src/_common/adapters/index.ts +++ b/packages/blocks/src/_common/adapters/index.ts @@ -5,4 +5,5 @@ export * from './markdown.js'; export * from './mix-text.js'; export * from './notion-html.js'; export * from './plain-text.js'; +export * from './uri-list.js'; export { fetchable, fetchImage } from './utils.js'; diff --git a/packages/blocks/src/_common/adapters/uri-list.ts b/packages/blocks/src/_common/adapters/uri-list.ts new file mode 100644 index 000000000000..4b821c23f037 --- /dev/null +++ b/packages/blocks/src/_common/adapters/uri-list.ts @@ -0,0 +1,60 @@ +import type { + BlockSnapshot, + DocSnapshot, + FromBlockSnapshotPayload, + FromBlockSnapshotResult, + FromDocSnapshotPayload, + FromDocSnapshotResult, + FromSliceSnapshotPayload, + FromSliceSnapshotResult, + SliceSnapshot, + ToBlockSnapshotPayload, + ToDocSnapshotPayload, + ToSliceSnapshotPayload, +} from '@blocksuite/store'; +import { BaseAdapter } from '@blocksuite/store'; + +export type UriList = string; + +// https://www.iana.org/assignments/media-types/text/uri-list +export class UriListAdapter extends BaseAdapter { + override fromDocSnapshot( + _payload: FromDocSnapshotPayload + ): Promise> { + throw new Error('Method not implemented.'); + } + override fromBlockSnapshot( + _payload: FromBlockSnapshotPayload + ): Promise> { + throw new Error('Method not implemented.'); + } + override fromSliceSnapshot( + _payload: FromSliceSnapshotPayload + ): Promise> { + throw new Error('Method not implemented.'); + } + override toDocSnapshot( + _payload: ToDocSnapshotPayload + ): Promise { + throw new Error('Method not implemented.'); + } + override toSliceSnapshot( + _payload: ToSliceSnapshotPayload + ): SliceSnapshot | Promise | null { + throw new Error('Method not implemented.'); + } + override toBlockSnapshot( + _payload: ToBlockSnapshotPayload + ): BlockSnapshot { + throw new Error('Method not implemented.'); + } + + parse(input: UriList) { + return input + .split('\n') + .map(line => line.trim()) + .filter(line => line.length >= 3) + .filter(line => line.at(0) !== '#') + .filter(line => URL.canParse(line)); + } +} diff --git a/packages/blocks/src/_common/components/file-drop-manager.ts b/packages/blocks/src/_common/components/file-drop-manager.ts index 49ce4dd2eb8e..6b9809e04a14 100644 --- a/packages/blocks/src/_common/components/file-drop-manager.ts +++ b/packages/blocks/src/_common/components/file-drop-manager.ts @@ -17,6 +17,8 @@ import type { DragIndicator } from './drag-indicator.js'; export type onDropProps = { files: File[]; + html?: string; + uriList?: string; targetModel: BlockModel | null; place: 'before' | 'after'; point: IPoint; @@ -26,6 +28,8 @@ export type FileDropOptions = { flavour: string; onDrop?: ({ files, + html, + uriList, targetModel, place, point, @@ -113,11 +117,12 @@ export class FileDropManager { onDragOver = (event: DragEvent) => { event.preventDefault(); + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return; + // allow only external drag-and-drop files - const effectAllowed = event.dataTransfer?.effectAllowed ?? 'none'; - if (effectAllowed !== 'all') { - return; - } + const effectAllowed = dataTransfer.effectAllowed; + if (effectAllowed === 'none') return; const { clientX, clientY } = event; const point = new Point(clientX, clientY); @@ -149,36 +154,41 @@ export class FileDropManager { this._indicator.rect = null; const { onDrop } = this._fileDropOptions; - if (!onDrop) { - return; - } + if (!onDrop) return; - event.preventDefault(); + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return; // allow only external drag-and-drop files - const effectAllowed = event.dataTransfer?.effectAllowed ?? 'none'; - if (effectAllowed !== 'all') { - return; + const effectAllowed = dataTransfer.effectAllowed; + if (effectAllowed === 'none') return; + + const types = dataTransfer.types; + if (!types.length) return; + + event.preventDefault(); + + let uriList: string | undefined; + if (types.includes('text/uri-list')) { + uriList = dataTransfer.getData('text/uri-list'); } - const droppedFiles = event.dataTransfer?.files; - if (!droppedFiles || !droppedFiles.length) { - return; + let html; + if (!uriList?.length && types.includes('text/html')) { + html = dataTransfer.getData('text/html'); } const targetModel = this.targetModel; const place = this.type; - - const { clientX, clientY } = event; + const point = { x: event.clientX, y: event.clientY }; onDrop({ - files: [...droppedFiles], + files: Array.from(dataTransfer.files), + html, + uriList, targetModel, place, - point: { - x: clientX, - y: clientY, - }, + point, })?.catch(console.error); }; } diff --git a/packages/blocks/src/attachment-block/attachment-service.ts b/packages/blocks/src/attachment-block/attachment-service.ts index 33fd32ffcd0e..acc955e6226f 100644 --- a/packages/blocks/src/attachment-block/attachment-service.ts +++ b/packages/blocks/src/attachment-block/attachment-service.ts @@ -4,6 +4,8 @@ import { assertExists } from '@blocksuite/global/utils'; import { Slot } from '@blocksuite/store'; import { render } from 'lit'; +import type { Html } from '../_common/adapters/html.js'; +import { UriListAdapter } from '../_common/adapters/uri-list.js'; import { FileDropManager, type FileDropOptions, @@ -11,6 +13,7 @@ import { import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../_common/consts.js'; import { matchFlavours } from '../_common/utils/model.js'; import { isInsideEdgelessEditor } from '../_common/utils/query.js'; +import { addSiblingImageBlock } from '../image-block/utils.js'; import type { EdgelessRootBlockComponent } from '../root-block/edgeless/edgeless-root-block.js'; import type { RootBlockComponent } from '../root-block/types.js'; import type { DragHandleOption } from '../root-block/widgets/drag-handle/config.js'; @@ -28,7 +31,13 @@ import { type AttachmentBlockModel, AttachmentBlockSchema, } from './attachment-model.js'; -import { addSiblingAttachmentBlocks } from './utils.js'; +import { + addSiblingAttachmentBlocks, + fetchAttachmentToFile, + fetchImageToFile, + isAttachment, + isImage, +} from './utils.js'; export class AttachmentService extends BlockService { get rootElement(): RootBlockComponent { @@ -50,29 +59,222 @@ export class AttachmentService extends BlockService { private _fileDropOptions: FileDropOptions = { flavour: this.flavour, - onDrop: async ({ files, targetModel, place, point }) => { - if (!files.length) return false; + onDrop: async ({ files, html, uriList, targetModel, place, point }) => { + if (!files.length && !html && !uriList?.length) return false; + + // in `page` mode or in edgeless's note + const isInline = + targetModel && !matchFlavours(targetModel, ['affine:surface']); + const isInEdgeless = isInsideEdgelessEditor(this.host as EditorHost); + const edgeless = isInEdgeless + ? (this.rootElement as EdgelessRootBlockComponent) + : null; // generic attachment block for all files except images const attachmentFiles = files.filter( file => !file.type.startsWith('image/') ); - if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) { - await addSiblingAttachmentBlocks( - this.host as EditorHost, - attachmentFiles, - this.maxFileSize, - targetModel, - place - ); - } else if (isInsideEdgelessEditor(this.host as EditorHost)) { - const edgelessRoot = this.rootElement as EdgelessRootBlockComponent; - point = edgelessRoot.service.viewport.toViewPointFromClientPoint(point); - await edgelessRoot.addAttachments(attachmentFiles, point); + const elements = []; + + if (attachmentFiles.length) { + if (isInline) { + await addSiblingAttachmentBlocks( + this.host as EditorHost, + attachmentFiles, + this.maxFileSize, + targetModel, + place + ); + } else if (isInEdgeless && edgeless) { + const p = edgeless.service.viewport.toViewPointFromClientPoint(point); + const ids = await edgeless.addAttachments(attachmentFiles, p); + elements.push(...ids); + } + + this.slots.onFilesDropped.emit(attachmentFiles); + } + + if (uriList?.length) { + const adapter = new UriListAdapter(); + const urls = adapter.parse(uriList); + const proxy = this.std.clipboard.configs.get('imageProxy'); + + if (isInline) { + const parent = this.doc.getParent(targetModel); + assertExists(parent); + + const items = []; + + for (const url of urls) { + if (isImage(url)) { + items.push( + fetchImageToFile(url, proxy).then(file => { + if (file) return { type: 'image', url, file }; + return null; + }) + ); + } else if (isAttachment(url)) { + items.push( + fetchAttachmentToFile(url, proxy).then(file => { + if (file) return { type: 'attachment', url, file }; + return null; + }) + ); + } else { + items.push( + Promise.resolve({ + url, + type: 'embed', + }) + ); + } + } + + const list = (await Promise.all(items)).filter(item => !!item) as { + url: string; + type: string; + file?: File; + }[]; + + let prevModel = targetModel; + let prevPlace = place; + for (const { url, type, file } of list) { + let result: string[] | undefined; + if (type === 'image' && file) { + result = addSiblingImageBlock( + this.host as EditorHost, + [file], + this.maxFileSize, + prevModel, + prevPlace + ); + } else if (type === 'attachment' && file) { + result = await addSiblingAttachmentBlocks( + this.host as EditorHost, + [file], + this.maxFileSize, + prevModel, + prevPlace + ); + } else { + result = this.std.doc.addSiblingBlocks( + prevModel, + [ + { + flavour: 'affine:bookmark', + url, + }, + ], + prevPlace + ); + } + if (result?.length === 1) { + prevModel = this.std.doc.getBlockById(result[0])!; + } + if (place === 'before') { + prevPlace = 'after'; + } + } + } else if (isInEdgeless && edgeless) { + const p = edgeless.service.viewport.toViewPointFromClientPoint(point); + const images = []; + const attachments = []; + const embeds = []; + + for (const url of urls) { + if (isImage(url)) { + images.push(url); + } else if (isAttachment(url)) { + attachments.push(url); + } else { + embeds.push(url); + } + } + + const imageFiles = ( + await Promise.all(images.map(url => fetchImageToFile(url, proxy))) + ).filter(f => !!f) as File[]; + + const attachmentFiles = ( + await Promise.all( + attachments.map(url => fetchAttachmentToFile(url, proxy)) + ) + ).filter(f => !!f) as File[]; + + if (imageFiles.length) { + const ids = await edgeless.addImages(imageFiles, p); + elements.push(...ids); + } + if (attachmentFiles.length) { + const ids = await edgeless.addAttachments(attachmentFiles, p); + elements.push(...ids); + } + + if (embeds.length) { + const [x, y] = edgeless.service.viewport.toModelCoord(p.x, p.y); + const ids = embeds.map(url => + edgeless.clipboardController.createEmbedBlockWith(url, { + x, + y, + }) + ); + elements.push(...ids); + } + } + } else if (html) { + try { + const snapshot = await this.std.clipboard + .getAdapter('text/html') + ?.toBlockSnapshot({ + file: html, + assets: this.std.clipboard.assetsManager, + }); + + if (snapshot) { + let parentId; + let index = 0; + let n = 0; + + if (isInline) { + const parent = this.doc.getParent(targetModel); + assertExists(parent); + parentId = parent.id; + + const targetIndex = + parent.children.findIndex(({ id }) => id === targetModel.id) ?? + 0; + index = place === 'before' ? targetIndex : targetIndex + 1; + } else if (isInEdgeless && edgeless) { + const p = + edgeless.service.viewport.toViewPointFromClientPoint(point); + parentId = edgeless.addNoteWithPoint(p, { center: true }); + elements.push(parentId); + } + + if (parentId) { + for (const block of snapshot.children) { + await this.std.clipboard.pasteBlockSnapshot( + block, + this.std.doc, + parentId, + index + n++ + ); + } + } + } + } catch (err) { + console.error(err); + } + } + + if (!isInline && isInEdgeless && edgeless && elements.length) { + edgeless.service.selection.set({ + elements, + editing: false, + }); } - this.slots.onFilesDropped.emit(attachmentFiles); return true; }, }; diff --git a/packages/blocks/src/attachment-block/utils.ts b/packages/blocks/src/attachment-block/utils.ts index 296ab65ed954..f70b87bb3ee5 100644 --- a/packages/blocks/src/attachment-block/utils.ts +++ b/packages/blocks/src/attachment-block/utils.ts @@ -2,6 +2,7 @@ import type { EditorHost } from '@blocksuite/block-std'; import { assertExists } from '@blocksuite/global/utils'; import type { BlockModel } from '@blocksuite/store'; +import { fetchImage } from '../_common/adapters/utils.js'; import { toast } from '../_common/components/toast.js'; import { humanFileSize } from '../_common/utils/math.js'; import type { AttachmentBlockComponent } from './attachment-block.js'; @@ -240,3 +241,73 @@ export async function addSiblingAttachmentBlocks( return blockIds; } + +const IMAGE_MIME_TYPES = [ + 'png', + 'jpg', + 'jpeg', + 'svg', + 'webp', + 'apng', + 'avif', + 'bpm', + 'gif', +]; + +export function isImage(url: string) { + return IMAGE_MIME_TYPES.some(ext => url.endsWith(`.${ext}`)); +} + +export function fetchImageToFile(url: string, proxy?: string) { + return fetchImage(url, undefined, proxy) + .then(res => (res.ok ? res.blob() : null)) + .then(blob => { + if (blob && blob.type.startsWith('image/')) { + return new File([blob], url.split('/').at(-1) || 'image', { + type: blob.type, + }); + } + return null; + }); +} + +const MEDIA_MIME_TYPES = [ + 'aac', + 'avi', + 'mp3', + 'mp4', + 'mpeg', + 'oga', + 'ogv', + 'ogx', + 'opus', + 'pdf', + 'wav', + 'weba', + 'webm', + '3gp', + '3g2', +]; + +export function isAttachment(url: string) { + return MEDIA_MIME_TYPES.some(ext => url.endsWith(`.${ext}`)); +} + +export function fetchAttachmentToFile(url: string, proxy?: string) { + // @TODO: fetch resource api + return fetchImage(url, undefined, proxy) + .then(res => (res.ok ? res.blob() : null)) + .then(blob => { + if ( + blob && + (blob.type.startsWith('video/') || + blob.type.startsWith('audio/') || + blob.type.startsWith('application/pdf')) + ) { + return new File([blob], url.split('/').at(-1) || 'media', { + type: blob.type, + }); + } + return null; + }); +} diff --git a/packages/blocks/src/root-block/clipboard/index.ts b/packages/blocks/src/root-block/clipboard/index.ts index aaa6a7a38465..575fa9b4ccf5 100644 --- a/packages/blocks/src/root-block/clipboard/index.ts +++ b/packages/blocks/src/root-block/clipboard/index.ts @@ -78,7 +78,7 @@ export class PageClipboard { this._disposables.add({ dispose: () => { this._std.clipboard.unregisterAdapter(ClipboardAdapter.MIME); - this._std.clipboard.unregisterAdapter('text/plain'); + this._std.clipboard.unregisterAdapter('text/html'); [ 'image/apng', 'image/avif', @@ -88,7 +88,7 @@ export class PageClipboard { 'image/svg+xml', 'image/webp', ].map(type => this._std.clipboard.unregisterAdapter(type)); - this._std.clipboard.unregisterAdapter('text/html'); + this._std.clipboard.unregisterAdapter('text/plain'); this._std.clipboard.unregisterAdapter('*/*'); this._std.clipboard.unuse(copy); this._std.clipboard.unuse(paste); diff --git a/packages/blocks/src/root-block/edgeless/controllers/clipboard.ts b/packages/blocks/src/root-block/edgeless/controllers/clipboard.ts index dc91de1bc187..74cf97b7da12 100644 --- a/packages/blocks/src/root-block/edgeless/controllers/clipboard.ts +++ b/packages/blocks/src/root-block/edgeless/controllers/clipboard.ts @@ -22,6 +22,7 @@ import { } from '../../../_common/consts.js'; import type { EdgelessModel, + IPoint, Selectable, TopLevelBlockModel, } from '../../../_common/types.js'; @@ -273,31 +274,7 @@ export class EdgelessClipboardController extends PageClipboard { lastMousePos.x, lastMousePos.y ); - - const embedOptions = this._rootService.getEmbedBlockOptions(url); - const flavour = embedOptions - ? (embedOptions.flavour as EdgelessElementType) - : 'affine:bookmark'; - const style = embedOptions ? embedOptions.styles[0] : BookmarkStyles[0]; - const width = EMBED_CARD_WIDTH[style]; - const height = EMBED_CARD_HEIGHT[style]; - - const id = this.host.service.addBlock( - flavour, - { - xywh: Bound.fromCenter( - Vec.toVec({ - x, - y, - }), - width, - height - ).serialize(), - url, - style, - }, - this.surface.model.id - ); + const id = this.createEmbedBlockWith(url, { x, y }); this.selectionManager.set({ editing: false, @@ -335,6 +312,26 @@ export class EdgelessClipboardController extends PageClipboard { } }; + createEmbedBlockWith(url: string, point: IPoint) { + const embedOptions = this.host.service.getEmbedBlockOptions(url); + const flavour = embedOptions + ? (embedOptions.flavour as EdgelessElementType) + : 'affine:bookmark'; + const style = embedOptions ? embedOptions.styles[0] : BookmarkStyles[0]; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + + return this.host.service.addBlock( + flavour, + { + xywh: Bound.fromCenter(Vec.toVec(point), width, height).serialize(), + url, + style, + }, + this.surface.model.id + ); + } + private _onCut = (_context: UIEventStateContext) => { const { selections, elements } = this.selectionManager; diff --git a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts index 2fd4b95318c2..8233218478a3 100644 --- a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts +++ b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts @@ -274,6 +274,7 @@ export class EdgelessRootBlockComponent extends BlockElement< offsetX?: number; offsetY?: number; scale?: number; + center?: boolean; } = {} ) { const { @@ -284,17 +285,20 @@ export class EdgelessRootBlockComponent extends BlockElement< parentId = this.doc.root?.id, noteIndex: noteIndex, scale = 1, + center = false, } = options; const [x, y] = this.service.viewport.toModelCoord(point.x, point.y); return this.service.addBlock( 'affine:note', { - xywh: serializeXYWH( - x - offsetX * scale, - y - offsetY * scale, - width, - height - ), + xywh: center + ? Bound.fromCenter([x, y], width, height).serialize() + : serializeXYWH( + x - offsetX * scale, + y - offsetY * scale, + width, + height + ), displayMode: NoteDisplayMode.EdgelessOnly, }, parentId, diff --git a/packages/blocks/src/root-block/widgets/block-hub/components/block-hub.ts b/packages/blocks/src/root-block/widgets/block-hub/components/block-hub.ts index d7887d5d4233..bc1472c997fc 100644 --- a/packages/blocks/src/root-block/widgets/block-hub/components/block-hub.ts +++ b/packages/blocks/src/root-block/widgets/block-hub/components/block-hub.ts @@ -443,7 +443,6 @@ export class BlockHub extends WithDisposable(ShadowlessElement) { const lastModelState = this._lastDroppingTarget; const lastType = this._lastDroppingType; const dataTransfer = e.dataTransfer; - assertExists(dataTransfer); const data = dataTransfer.getData('affine/block-hub'); const models = []; diff --git a/packages/framework/block-std/src/clipboard/index.ts b/packages/framework/block-std/src/clipboard/index.ts index aaab614cf4f8..948a622bd914 100644 --- a/packages/framework/block-std/src/clipboard/index.ts +++ b/packages/framework/block-std/src/clipboard/index.ts @@ -39,10 +39,20 @@ export class Clipboard { this._adapterMap.delete(mimeType); }; + getAdapter(mimeType: string) { + const item = this._adapterMap.get(mimeType); + if (item) return item.adapter as BaseAdapter; + return item; + } + get configs() { return this._getJob().adapterConfigs; } + get assetsManager() { + return this._getJob().assetsManager; + } + private _getJob() { return new Job({ middlewares: this._jobMiddlewares, @@ -252,6 +262,7 @@ export class Clipboard { } }) ); + return items; }); };