diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx
index 5e88baf2..0d36c2af 100644
--- a/components/Blocks/Block.tsx
+++ b/components/Blocks/Block.tsx
@@ -7,6 +7,8 @@ import { useBlockMouseHandlers } from "./useBlockMouseHandlers";
import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers";
import { useLongPress } from "src/hooks/useLongPress";
import { focusBlock } from "src/utils/focusBlock";
+import { useHandleDrop } from "./useHandleDrop";
+import { useEntitySetContext } from "components/EntitySetProvider";
import { TextBlock } from "components/Blocks/TextBlock";
import { ImageBlock } from "./ImageBlock";
@@ -15,7 +17,6 @@ import { ExternalLinkBlock } from "./ExternalLinkBlock";
import { EmbedBlock } from "./EmbedBlock";
import { MailboxBlock } from "./MailboxBlock";
import { AreYouSure } from "./DeleteBlock";
-import { useEntitySetContext } from "components/EntitySetProvider";
import { useIsMobile } from "src/hooks/isMobile";
import { DateTimeBlock } from "./DateTimeBlock";
import { RSVPBlock } from "./RSVPBlock";
@@ -63,6 +64,12 @@ export const Block = memo(function Block(
// and shared styling like padding and flex for list layouting
let mouseHandlers = useBlockMouseHandlers(props);
+ let handleDrop = useHandleDrop({
+ parent: props.parent,
+ position: props.position,
+ nextPosition: props.nextPosition,
+ });
+ let entity_set = useEntitySetContext();
let { isLongPress, handlers } = useLongPress(() => {
if (isTextBlock[props.type]) return;
@@ -94,6 +101,17 @@ export const Block = memo(function Block(
id={
!props.preview ? elementId.block(props.entityID).container : undefined
}
+ onDragOver={
+ !props.preview && entity_set.permissions.write
+ ? (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ : undefined
+ }
+ onDrop={
+ !props.preview && entity_set.permissions.write ? handleDrop : undefined
+ }
className={`
blockWrapper relative
flex flex-row gap-2
diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx
index 45d3a4ed..f88e46b7 100644
--- a/components/Blocks/ImageBlock.tsx
+++ b/components/Blocks/ImageBlock.tsx
@@ -51,6 +51,34 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) {
}
}, [isSelected, props.preview, props.entityID]);
+ const handleImageUpload = async (file: File) => {
+ if (!rep) return;
+ let entity = props.entityID;
+ if (!entity) {
+ entity = v7();
+ await rep?.mutate.addBlock({
+ parent: props.parent,
+ factID: v7(),
+ permission_set: entity_set.set,
+ type: "text",
+ position: generateKeyBetween(
+ props.position,
+ props.nextPosition,
+ ),
+ newEntityID: entity,
+ });
+ }
+ await rep.mutate.assertFact({
+ entity,
+ attribute: "block/type",
+ data: { type: "block-type-union", value: "image" },
+ });
+ await addImage(file, rep, {
+ entityID: entity,
+ attribute: "block/image",
+ });
+ };
+
if (!image) {
if (!entity_set.permissions.write) return null;
return (
@@ -65,6 +93,22 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) {
${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"}
${props.pageType === "canvas" && "bg-bg-page"}`}
onMouseDown={(e) => e.preventDefault()}
+ onDragOver={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onDrop={async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (isLocked) return;
+ const files = e.dataTransfer.files;
+ if (files && files.length > 0) {
+ const file = files[0];
+ if (file.type.startsWith('image/')) {
+ await handleImageUpload(file);
+ }
+ }
+ }}
>
{
let file = e.currentTarget.files?.[0];
- if (!file || !rep) return;
- let entity = props.entityID;
- if (!entity) {
- entity = v7();
- await rep?.mutate.addBlock({
- parent: props.parent,
- factID: v7(),
- permission_set: entity_set.set,
- type: "text",
- position: generateKeyBetween(
- props.position,
- props.nextPosition,
- ),
- newEntityID: entity,
- });
- }
- await rep.mutate.assertFact({
- entity,
- attribute: "block/type",
- data: { type: "block-type-union", value: "image" },
- });
- await addImage(file, rep, {
- entityID: entity,
- attribute: "block/image",
- });
+ if (!file) return;
+ await handleImageUpload(file);
}}
/>
diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx
index 8773948e..72166f0e 100644
--- a/components/Blocks/index.tsx
+++ b/components/Blocks/index.tsx
@@ -17,6 +17,7 @@ import { Block } from "./Block";
import { useEffect } from "react";
import { addShortcut } from "src/shortcuts";
import { QuoteEmbedBlock } from "./QuoteEmbedBlock";
+import { useHandleDrop } from "./useHandleDrop";
export function Blocks(props: { entityID: string }) {
let rep = useReplicache();
@@ -231,6 +232,11 @@ const BlockListBottom = (props: {
}) => {
let { rep } = useReplicache();
let entity_set = useEntitySetContext();
+ let handleDrop = useHandleDrop({
+ parent: props.entityID,
+ position: props.lastRootBlock?.position || null,
+ nextPosition: null,
+ });
if (!entity_set.permissions.write) return;
return (
@@ -267,6 +273,11 @@ const BlockListBottom = (props: {
}, 10);
}
}}
+ onDragOver={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onDrop={handleDrop}
/>
);
};
diff --git a/components/Blocks/useHandleDrop.ts b/components/Blocks/useHandleDrop.ts
new file mode 100644
index 00000000..4c3dbb3f
--- /dev/null
+++ b/components/Blocks/useHandleDrop.ts
@@ -0,0 +1,74 @@
+import { useCallback } from "react";
+import { useReplicache } from "src/replicache";
+import { generateKeyBetween } from "fractional-indexing";
+import { addImage } from "src/utils/addImage";
+import { useEntitySetContext } from "components/EntitySetProvider";
+import { v7 } from "uuid";
+
+export const useHandleDrop = (params: {
+ parent: string;
+ position: string | null;
+ nextPosition: string | null;
+}) => {
+ let { rep } = useReplicache();
+ let entity_set = useEntitySetContext();
+
+ return useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!rep) return;
+
+ const files = e.dataTransfer.files;
+ if (!files || files.length === 0) return;
+
+ // Filter for image files only
+ const imageFiles = Array.from(files).filter((file) =>
+ file.type.startsWith("image/"),
+ );
+
+ if (imageFiles.length === 0) return;
+
+ let currentPosition = params.position;
+
+ // Calculate positions for all images first
+ const imageBlocks = imageFiles.map((file) => {
+ const entity = v7();
+ const position = generateKeyBetween(
+ currentPosition,
+ params.nextPosition,
+ );
+ currentPosition = position;
+ return { file, entity, position };
+ });
+
+ // Create all blocks in parallel
+ await Promise.all(
+ imageBlocks.map((block) =>
+ rep.mutate.addBlock({
+ parent: params.parent,
+ factID: v7(),
+ permission_set: entity_set.set,
+ type: "image",
+ position: block.position,
+ newEntityID: block.entity,
+ }),
+ ),
+ );
+
+ // Upload all images in parallel
+ await Promise.all(
+ imageBlocks.map((block) =>
+ addImage(block.file, rep, {
+ entityID: block.entity,
+ attribute: "block/image",
+ }),
+ ),
+ );
+
+ return true;
+ },
+ [rep, params.position, params.nextPosition, params.parent, entity_set.set],
+ );
+};