From c1ad681300d19cc33879c21ec4f6e37b9e477eed Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Oct 2025 12:24:17 -0400 Subject: [PATCH] handle dropping images --- components/Blocks/Block.tsx | 20 +++++++- components/Blocks/ImageBlock.tsx | 71 ++++++++++++++++++---------- components/Blocks/index.tsx | 11 +++++ components/Blocks/useHandleDrop.ts | 74 ++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 components/Blocks/useHandleDrop.ts 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], + ); +}; -- 2.51.2