Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
8.7 kB · 282 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283"use client";
import { Fact, useEntity, useReplicache } from "src/replicache";
import { useUIState } from "src/useUIState";import { useBlocks } from "src/hooks/queries/useBlocks";import { useEditorStates } from "src/state/useEditorState";import { useEntitySetContext } from "components/EntitySetProvider";
import { isTextBlock } from "src/utils/isTextBlock";import { focusBlock } from "src/utils/focusBlock";import { elementId } from "src/utils/elementId";import { generateKeyBetween } from "fractional-indexing";import { v7 } from "uuid";
import { Block } from "./Block";import { useEffect } from "react";import { addShortcut } from "src/shortcuts";import { useHandleDrop } from "./useHandleDrop";
export function Blocks(props: { entityID: string }) { let rep = useReplicache(); let isPageFocused = useUIState((s) => { let focusedElement = s.focusedEntity; let focusedPageID = focusedElement?.entityType === "page" ? focusedElement.entityID : focusedElement?.parent; return focusedPageID === props.entityID; }); let { permissions } = useEntitySetContext(); let entity_set = useEntitySetContext(); let blocks = useBlocks(props.entityID); let foldedBlocks = useUIState((s) => s.foldedBlocks); useEffect(() => { if (!isPageFocused) return; return addShortcut([ { altKey: true, metaKey: true, key: "ArrowUp", shift: true, handler: () => { let allParents = blocks.reduce((acc, block) => { if (!block.listData) return acc; block.listData.path.forEach((p) => !acc.includes(p.entity) ? acc.push(p.entity) : null, ); return acc; }, [] as string[]); useUIState.setState((s) => { let foldedBlocks = [...s.foldedBlocks]; allParents.forEach((p) => { if (!foldedBlocks.includes(p)) foldedBlocks.push(p); }); return { foldedBlocks }; }); }, }, { altKey: true, metaKey: true, key: "ArrowDown", shift: true, handler: () => { let allParents = blocks.reduce((acc, block) => { if (!block.listData) return acc; block.listData.path.forEach((p) => !acc.includes(p.entity) ? acc.push(p.entity) : null, ); return acc; }, [] as string[]); useUIState.setState((s) => { let foldedBlocks = [...s.foldedBlocks].filter( (f) => !allParents.includes(f), ); return { foldedBlocks }; }); }, }, ]); }, [blocks, isPageFocused]);
let lastRootBlock = blocks.findLast( (f) => !f.listData || f.listData.depth === 1, );
let lastVisibleBlock = blocks.findLast( (f) => !f.listData || !f.listData.path.find( (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, ), );
return ( <div className={`blocks w-full flex flex-col outline-hidden h-fit min-h-full`} onClick={async (e) => { if (!permissions.write) return; if (useUIState.getState().selectedBlocks.length > 1) return; if (e.target === e.currentTarget) { if ( !lastVisibleBlock || (lastVisibleBlock.type !== "text" && lastVisibleBlock.type !== "heading") ) { let newEntityID = v7(); await rep.rep?.mutate.addBlock({ parent: props.entityID, factID: v7(), permission_set: entity_set.set, type: "text", position: generateKeyBetween( lastRootBlock?.position || null, null, ), newEntityID, });
setTimeout(() => { document .getElementById(elementId.block(newEntityID).text) ?.focus(); }, 10); } else { lastVisibleBlock && focusBlock(lastVisibleBlock, { type: "end" }); } } }} > {blocks .filter( (f) => !f.listData || !f.listData.path.find( (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, ), ) .map((f, index, arr) => { let nextBlock = arr[index + 1]; let depth = f.listData?.depth || 1; let nextDepth = nextBlock?.listData?.depth || 1; let nextPosition: string | null; if (depth === nextDepth) nextPosition = nextBlock?.position || null; else nextPosition = null; return ( <Block pageType="doc" {...f} key={f.value} entityID={f.value} parent={props.entityID} previousBlock={arr[index - 1] || null} nextBlock={arr[index + 1] || null} nextPosition={nextPosition} /> ); })} <NewBlockButton lastBlock={lastRootBlock || null} entityID={props.entityID} />
<BlockListBottom lastVisibleBlock={lastVisibleBlock || undefined} lastRootBlock={lastRootBlock || undefined} entityID={props.entityID} /> </div> );}
function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { let { rep } = useReplicache(); let entity_set = useEntitySetContext(); let editorState = useEditorStates((s) => props.lastBlock?.type === "text" ? s.editorStates[props.lastBlock.value] : null, );
let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); if (!entity_set.permissions.write) return null; if ( ((props.lastBlock?.type === "text" && !isLocked?.data.value) || props.lastBlock?.type === "heading") && (!editorState?.editor || editorState.editor.doc.content.size <= 2) ) return null; return ( <div className="flex items-center justify-between group/text px-3 sm:px-4"> <div className="h-6 hover:cursor-text italic text-tertiary grow" onMouseDown={async () => { let newEntityID = v7(); await rep?.mutate.addBlock({ parent: props.entityID, type: "text", factID: v7(), permission_set: entity_set.set, position: generateKeyBetween( props.lastBlock?.position || null, null, ), newEntityID, });
setTimeout(() => { document.getElementById(elementId.block(newEntityID).text)?.focus(); }, 10); }} > {/* this is here as a fail safe, in case a new page is created and there are no blocks in it yet, we render a newblockbutton with a textblock-like placeholder instead of a proper first block. */} {!props.lastBlock ? ( <div className="pt-2 sm:pt-3">write something...</div> ) : ( " " )} </div> </div> );}
const BlockListBottom = (props: { lastRootBlock: Block | undefined; lastVisibleBlock: Block | undefined; entityID: string;}) => { 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 ( <div className="blockListClickableBottomArea shrink-0 h-[50vh]" onClick={() => { let newEntityID = v7(); if ( // if the last visible(not-folded) block is a text block, focus it props.lastRootBlock && props.lastVisibleBlock && isTextBlock[props.lastVisibleBlock.type] ) { focusBlock( { ...props.lastVisibleBlock, type: "text" }, { type: "end" }, ); } else { // else add a new text block at the end and focus it rep?.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), parent: props.entityID, type: "text", position: generateKeyBetween( props.lastRootBlock?.position || null, null, ), newEntityID, });
setTimeout(() => { document.getElementById(elementId.block(newEntityID).text)?.focus(); }, 10); } }} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} onDrop={handleDrop} /> );};