Skip to content

Commit 3367f4b

Browse files
authored
feat: support uploading files dragged and dropped from device (#214)
1 parent eb11125 commit 3367f4b

File tree

3 files changed

+137
-15
lines changed

3 files changed

+137
-15
lines changed
Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,60 @@
1-
import {Fragment, Node, Slice} from 'prosemirror-model';
1+
import {Fragment, Node, Schema, Slice} from 'prosemirror-model';
22
import {Plugin} from 'prosemirror-state';
3+
import {dropPoint} from 'prosemirror-transform';
34

4-
import {ExtensionAuto} from '../../../core';
5+
import type {ExtensionAuto} from '../../../core';
56
import {pType} from '../../../extensions/base';
67
import {isFilesOnly} from '../Clipboard/utils';
78

8-
// Custom handler of pasted files to prevent insert a file preview in base64-format into editor's content
9+
// Custom handler of pasted (or dropped) files to prevent insert a file preview in base64-format into editor's content (or open file in browser)
910
export const FilePaste: ExtensionAuto = (builder) => {
10-
builder.addPlugin(({schema}) => {
11+
builder.addPlugin(() => {
1112
return new Plugin({
1213
props: {
1314
handlePaste(view, event) {
14-
const {clipboardData} = event;
15-
if (!clipboardData || !isFilesOnly(clipboardData)) return false;
16-
const nodes: Node[] = [];
17-
for (const file of Array.from(clipboardData.files)) {
18-
nodes.push(pType(schema).create(null, schema.text(file.name)));
19-
}
15+
const files = getFiles(event.clipboardData);
16+
if (!files) return false;
17+
2018
view.dispatch(
2119
view.state.tr
22-
.replaceSelection(new Slice(Fragment.from(nodes), 0, 0))
20+
.replaceSelection(createFilesSlice(view.state.schema, files))
2321
.scrollIntoView(),
2422
);
23+
24+
return true;
25+
},
26+
handleDrop(view, event) {
27+
if (view.dragging) return false;
28+
29+
const files = getFiles(event.dataTransfer);
30+
if (!files) return false;
31+
32+
const slice = createFilesSlice(view.state.schema, files);
33+
34+
const dropPos =
35+
view.posAtCoords({left: event.clientX, top: event.clientY})?.pos ?? -1;
36+
if (dropPos === -1) return false;
37+
38+
const posToInsert = dropPoint(view.state.doc, dropPos, slice);
39+
if (posToInsert === null) return false;
40+
41+
view.dispatch(view.state.tr.insert(posToInsert, slice.content));
2542
return true;
2643
},
2744
},
2845
});
2946
}, builder.Priority.Lowest);
3047
};
48+
49+
function getFiles(dataTransfer: DataTransfer | null) {
50+
if (!dataTransfer || !isFilesOnly(dataTransfer)) return null;
51+
return Array.from(dataTransfer.files);
52+
}
53+
54+
function createFilesSlice(schema: Schema, files: File[]): Slice {
55+
const nodes: Node[] = [];
56+
for (const file of files) {
57+
nodes.push(pType(schema).create(null, schema.text(file.name)));
58+
}
59+
return new Slice(Fragment.from(nodes), 0, 0);
60+
}

src/extensions/behavior/ImgSizeAdditions/ImagePaste/index.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {decode as base64ToBuffer} from 'base64-arraybuffer';
2-
import {Node, Slice} from 'prosemirror-model';
2+
import {Fragment, Node, Schema, Slice} from 'prosemirror-model';
33
import {Plugin} from 'prosemirror-state';
4+
import {dropPoint} from 'prosemirror-transform';
45

56
import {ExtensionAuto} from '../../../../core';
6-
import {ImgSizeAttr} from '../../../../extensions/specs';
7+
import {ImageAttr, ImgSizeAttr, imageType} from '../../../../extensions/specs';
78
import {isFunction} from '../../../../lodash';
89
import {FileUploadHandler} from '../../../../utils/upload';
910
import {clipboardUtils} from '../../Clipboard';
@@ -41,6 +42,36 @@ export const ImagePaste: ExtensionAuto<ImagePasteOptions> = (builder, opts) => {
4142
}
4243
return false;
4344
},
45+
drop(view, e) {
46+
// handle drop images from device
47+
if (view.dragging) return false;
48+
49+
const files = getPastedImages(e.dataTransfer);
50+
if (!files) return false;
51+
52+
const dropPos =
53+
view.posAtCoords({left: e.clientX, top: e.clientY})?.pos ?? -1;
54+
if (dropPos === -1) return false;
55+
56+
const posToInsert = dropPoint(
57+
view.state.doc,
58+
dropPos,
59+
createFakeImageSlice(view.state.schema),
60+
);
61+
62+
if (posToInsert !== null) {
63+
new ImagesUploadProcess(
64+
view,
65+
files,
66+
opts.imageUploadHandler,
67+
posToInsert,
68+
opts,
69+
).run();
70+
}
71+
72+
e.preventDefault();
73+
return true;
74+
},
4475
},
4576
handlePaste(view, _event, slice) {
4677
const node = sliceSingleNode(slice);
@@ -73,6 +104,19 @@ function getPastedImages(data: DataTransfer | null): File[] | null {
73104
return files.every(isImageFile) ? files : null;
74105
}
75106

107+
function createFakeImageSlice(schema: Schema): Slice {
108+
return new Slice(
109+
Fragment.from(
110+
imageType(schema).create({
111+
[ImageAttr.Src]: 'fake',
112+
[ImageAttr.Title]: 'image',
113+
}),
114+
),
115+
0,
116+
0,
117+
);
118+
}
119+
76120
// copied from prosemirror-view input.ts
77121
function sliceSingleNode(slice: Slice): Node | null {
78122
return slice.openStart === 0 && slice.openEnd === 0 && slice.content.childCount === 1

src/extensions/behavior/YfmFileAdditions/YfmFilePaste/index.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import {Node} from 'prosemirror-model';
1+
import {Fragment, Node, Schema, Slice} from 'prosemirror-model';
22
import {Plugin} from 'prosemirror-state';
3+
import {dropPoint} from 'prosemirror-transform';
34
import {EditorView} from 'prosemirror-view';
45

56
import {ExtensionAuto} from '../../../../core';
67
import {imageType} from '../../../../extensions/markdown';
8+
import {fileType} from '../../../../extensions/yfm/YfmFile';
79
import {isFunction} from '../../../../lodash';
810
import {FileUploadHandler, UploadSuccessItem} from '../../../../utils/upload';
911
import {clipboardUtils} from '../../Clipboard';
@@ -40,6 +42,35 @@ export const YfmFilePaste: ExtensionAuto<YfmFilePasteOptions> = (builder, opts)
4042
}
4143
return false;
4244
},
45+
drop(view, e) {
46+
// handle drop files from device
47+
if (view.dragging) return false;
48+
49+
const files = getPastedFiles(e.dataTransfer);
50+
if (!files) return false;
51+
52+
const dropPos =
53+
view.posAtCoords({left: e.clientX, top: e.clientY})?.pos ?? -1;
54+
if (dropPos === -1) return false;
55+
56+
const posToInsert = dropPoint(
57+
view.state.doc,
58+
dropPos,
59+
createFakeFileSlice(view.state.schema),
60+
);
61+
62+
if (posToInsert !== null) {
63+
new YfmFilesPasteUploadProcess(view, files, {
64+
pos: posToInsert,
65+
uploadHandler: opts.fileUploadHandler,
66+
needToSetDimensionsForUploadedImages:
67+
opts.needToSetDimensionsForUploadedImages,
68+
}).run();
69+
}
70+
71+
e.preventDefault();
72+
return true;
73+
},
4374
},
4475
},
4576
}),
@@ -53,17 +84,34 @@ function getPastedFiles(data: DataTransfer | null): File[] | null {
5384
return Array.from(data.files);
5485
}
5586

87+
function createFakeFileSlice(schema: Schema): Slice {
88+
return new Slice(
89+
Fragment.from(
90+
fileType(schema).create({
91+
href: 'fake',
92+
download: 'file',
93+
}),
94+
),
95+
0,
96+
0,
97+
);
98+
}
99+
56100
type YfmFilesPasteUploadProcessOptions = {
101+
pos?: number;
57102
uploadHandler: FileUploadHandler;
58103
needToSetDimensionsForUploadedImages: boolean;
59104
};
60105

61106
class YfmFilesPasteUploadProcess extends YfmFilesUploadProcessBase {
107+
protected readonly pos?: number;
62108
protected readonly createImage?;
63109

64110
constructor(view: EditorView, files: readonly File[], opts: YfmFilesPasteUploadProcessOptions) {
65111
super(view, files, opts.uploadHandler);
66112

113+
this.pos = opts.pos;
114+
67115
const {schema} = this.view.state;
68116
if (imageType(schema)) {
69117
this.createImage = createImageNode(imageType(schema), {
@@ -73,7 +121,7 @@ class YfmFilesPasteUploadProcess extends YfmFilesUploadProcessBase {
73121
}
74122

75123
protected getSkeletonInitPos(): number {
76-
return this.view.state.tr.selection.from;
124+
return this.pos ?? this.view.state.tr.selection.from;
77125
}
78126

79127
protected async createPMNode(res: UploadSuccessItem): Promise<Node> {

0 commit comments

Comments
 (0)