Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
20 kB · 594 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595import { useEntity, useReplicache } from "src/replicache";import { useEntitySetContext } from "./EntitySetProvider";import { v7 } from "uuid";import { BaseBlock } from "./Blocks/Block";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useDrag } from "src/hooks/useDrag";import { useLongPress } from "src/hooks/useLongPress";import { focusBlock } from "src/utils/focusBlock";import { elementId } from "src/utils/elementId";import { useUIState } from "src/useUIState";import useMeasure from "react-use-measure";import { useIsMobile } from "src/hooks/isMobile";import { Media } from "./Media";import { TooltipButton } from "./Buttons";import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers";import { AddSmall } from "./Icons/AddSmall";import { InfoSmall } from "./Icons/InfoSmall";import { Popover } from "./Popover";import { Separator } from "./Layout";import { CommentTiny } from "./Icons/CommentTiny";import { QuoteTiny } from "./Icons/QuoteTiny";import { PublicationMetadata } from "./Pages/PublicationMetadata";import { useLeafletPublicationData } from "./PageSWRDataProvider";import { PubLeafletPublication, PubLeafletPublicationRecord,} from "lexicons/api";import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
export function Canvas(props: { entityID: string; preview?: boolean; first?: boolean;}) { let entity_set = useEntitySetContext(); let ref = useRef<HTMLDivElement>(null); useEffect(() => { let abort = new AbortController(); let isTouch = false; let startX: number, startY: number, scrollLeft: number, scrollTop: number; let el = ref.current; ref.current?.addEventListener( "wheel", (e) => { if (!el) return; if ( (e.deltaX > 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth) || (e.deltaX < 0 && el.scrollLeft <= 0) || (e.deltaY > 0 && el.scrollTop >= el.scrollHeight - el.clientHeight) || (e.deltaY < 0 && el.scrollTop <= 0) ) { return; } e.preventDefault(); el.scrollLeft += e.deltaX; el.scrollTop += e.deltaY; }, { passive: false, signal: abort.signal }, ); return () => abort.abort(); });
return ( <div ref={ref} id={elementId.page(props.entityID).canvasScrollArea} className={` canvasWrapper h-full w-fit overflow-y-scroll `} > <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} />
<CanvasMetadata isSubpage={!props.first} />
<CanvasContent {...props} /> </div> );}
export function CanvasContent(props: { entityID: string; preview?: boolean }) { let blocks = useEntity(props.entityID, "canvas/block"); let { rep } = useReplicache(); let entity_set = useEntitySetContext(); let height = Math.max(...blocks.map((f) => f.data.position.y), 0); let handleDrop = useHandleCanvasDrop(props.entityID);
return ( <div onClick={async (e) => { if (e.currentTarget !== e.target) return; useUIState.setState(() => ({ selectedBlocks: [], focusedEntity: { entityType: "page", entityID: props.entityID }, })); useUIState.setState({ focusedEntity: { entityType: "page", entityID: props.entityID }, }); document .getElementById(elementId.page(props.entityID).container) ?.scrollIntoView({ behavior: "smooth", inline: "nearest", }); if (e.detail === 2 || e.ctrlKey || e.metaKey) { let parentRect = e.currentTarget.getBoundingClientRect(); let newEntityID = v7(); await rep?.mutate.addCanvasBlock({ newEntityID, parent: props.entityID, position: { x: Math.max(e.clientX - parentRect.left, 0), y: Math.max(e.clientY - parentRect.top - 12, 0), }, factID: v7(), type: "text", permission_set: entity_set.set, }); focusBlock( { type: "text", parent: props.entityID, value: newEntityID }, { type: "start" }, ); } }} onDragOver={ !props.preview && entity_set.permissions.write ? (e) => { e.preventDefault(); e.stopPropagation(); } : undefined } onDrop={ !props.preview && entity_set.permissions.write ? handleDrop : undefined } style={{ minHeight: height + 512, contain: "size layout paint", }} className="relative h-full w-[1272px]" > <CanvasBackground entityID={props.entityID} /> {blocks .sort((a, b) => { if (a.data.position.y === b.data.position.y) { return a.data.position.x - b.data.position.x; } return a.data.position.y - b.data.position.y; }) .map((b) => { return ( <CanvasBlock preview={props.preview} parent={props.entityID} entityID={b.data.value} position={b.data.position} factID={b.id} key={b.id} /> ); })} </div> );}
const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { let { data: pub } = useLeafletPublicationData(); if (!pub || !pub.publications) return null;
let pubRecord = pub.publications.record as PubLeafletPublication.Record; let showComments = pubRecord.preferences?.showComments;
return ( <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> {showComments && ( <div className="flex gap-1 text-tertiary items-center"> <CommentTiny className="text-border" /> — </div> )} <div className="flex gap-1 text-tertiary items-center"> <QuoteTiny className="text-border" /> — </div>
{!props.isSubpage && ( <> <Separator classname="h-5" /> <Popover side="left" align="start" className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" trigger={<InfoSmall />} > <PublicationMetadata /> </Popover> </> )} </div> );};
const AddCanvasBlockButton = (props: { entityID: string; entity_set: { set: string };}) => { let { rep } = useReplicache(); let { permissions } = useEntitySetContext(); let blocks = useEntity(props.entityID, "canvas/block");
if (!permissions.write) return null; return ( <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center"> <TooltipButton side="left" open={blocks.length === 0 ? true : undefined} tooltipContent={ <div className="flex flex-col justify-end text-center px-1 leading-snug "> <div>Add a Block!</div> <div className="font-normal">or double click anywhere</div> </div> } className="w-fit p-2 rounded-full bg-accent-1 border-2 outline-solid outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-2" onMouseDown={() => { let page = document.getElementById( elementId.page(props.entityID).canvasScrollArea, ); if (!page) return; let newEntityID = v7(); rep?.mutate.addCanvasBlock({ newEntityID, parent: props.entityID, position: { x: page?.clientWidth + page?.scrollLeft - 468, y: 32 + page.scrollTop, }, factID: v7(), type: "text", permission_set: props.entity_set.set, }); setTimeout(() => { focusBlock( { type: "text", value: newEntityID, parent: props.entityID }, { type: "start" }, ); }, 20); }} > <AddSmall /> </TooltipButton> </div> );};
function CanvasBlock(props: { preview?: boolean; entityID: string; parent: string; position: { x: number; y: number }; factID: string;}) { let width = useEntity(props.entityID, "canvas/block/width")?.data.value || 360; let rotation = useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0; let [ref, rect] = useMeasure(); let type = useEntity(props.entityID, "block/type"); let { rep } = useReplicache(); let isMobile = useIsMobile();
let { permissions } = useEntitySetContext(); let onDragEnd = useCallback( (dragPosition: { x: number; y: number }) => { if (!permissions.write) return; rep?.mutate.assertFact({ id: props.factID, entity: props.parent, attribute: "canvas/block", data: { type: "spatial-reference", value: props.entityID, position: { x: props.position.x + dragPosition.x, y: props.position.y + dragPosition.y, }, }, }); }, [props, rep, permissions], ); let { dragDelta, handlers } = useDrag({ onDragEnd, delay: isMobile, });
let widthOnDragEnd = useCallback( (dragPosition: { x: number; y: number }) => { rep?.mutate.assertFact({ entity: props.entityID, attribute: "canvas/block/width", data: { type: "number", value: width + dragPosition.x, }, }); }, [props, rep, width], ); let widthHandle = useDrag({ onDragEnd: widthOnDragEnd });
let RotateOnDragEnd = useCallback( (dragDelta: { x: number; y: number }) => { let originX = rect.x + rect.width / 2; let originY = rect.y + rect.height / 2;
let angle = find_angle( { x: rect.x + rect.width, y: rect.y + rect.height }, { x: originX, y: originY }, { x: rect.x + rect.width + dragDelta.x, y: rect.y + rect.height + dragDelta.y, }, ) * (180 / Math.PI);
rep?.mutate.assertFact({ entity: props.entityID, attribute: "canvas/block/rotation", data: { type: "number", value: (rotation + angle) % 360, }, }); }, [props, rep, rect, rotation], ); let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd });
let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { if (isLongPress.current && permissions.write) { focusBlock( { type: type?.data.value || "text", value: props.entityID, parent: props.parent, }, { type: "start" }, ); } }); let angle = 0; if (rotateHandle.dragDelta) { let originX = rect.x + rect.width / 2; let originY = rect.y + rect.height / 2;
angle = find_angle( { x: rect.x + rect.width, y: rect.y + rect.height }, { x: originX, y: originY }, { x: rect.x + rect.width + rotateHandle.dragDelta.x, y: rect.y + rect.height + rotateHandle.dragDelta.y, }, ) * (180 / Math.PI); } let x = props.position.x + (dragDelta?.x || 0); let y = props.position.y + (dragDelta?.y || 0); let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; let [areYouSure, setAreYouSure] = useState(false); let blockProps = useMemo(() => { return { pageType: "canvas" as const, preview: props.preview, type: type?.data.value || "text", value: props.entityID, factID: props.factID, position: "", nextPosition: "", entityID: props.entityID, parent: props.parent, nextBlock: null, previousBlock: null, }; }, [props, type?.data.value]); useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); let isList = useEntity(props.entityID, "block/is-list"); let isFocused = useUIState( (s) => s.focusedEntity?.entityID === props.entityID, );
return ( <div ref={ref} {...(!props.preview ? { ...longPressHandlers } : {})} {...(isMobile && permissions.write ? { ...handlers } : {})} id={props.preview ? undefined : elementId.block(props.entityID).container} className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} style={{ top: 0, left: 0, zIndex: dragDelta || isFocused ? 10 : undefined, width: width + (widthHandle.dragDelta?.x || 0), transform, }} > {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} {!props.preview && permissions.write && <Gripper {...handlers} />} <div className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} > <BaseBlock {...blockProps} listData={ isList?.data.value ? { path: [], parent: props.parent, depth: 1 } : undefined } areYouSure={areYouSure} setAreYouSure={setAreYouSure} /> </div>
{!props.preview && permissions.write && ( <div className={`resizeHandle cursor-e-resize shrink-0 z-10 hidden group-hover/canvas-block:block w-[5px] h-6 -ml-[3px] absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} {...widthHandle.handlers} /> )}
{!props.preview && permissions.write && ( <div className={`rotateHandle cursor-grab shrink-0 z-10 hidden group-hover/canvas-block:block w-[8px] h-[8px] absolute bottom-0 -right-0 -translate-y-1/2 -translate-x-1/2 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} {...rotateHandle.handlers} /> )} </div> );}
export const CanvasBackground = (props: { entityID: string }) => { let cardBackgroundImage = useEntity( props.entityID, "theme/card-background-image", ); let cardBackgroundImageRepeat = useEntity( props.entityID, "theme/card-background-image-repeat", ); let cardBackgroundImageOpacity = useEntity(props.entityID, "theme/card-background-image-opacity")?.data .value || 1;
let canvasPattern = useEntity(props.entityID, "canvas/background-pattern")?.data.value || "grid"; return ( <div className="w-full h-full pointer-events-none" style={{ backgroundImage: cardBackgroundImage ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` : undefined, backgroundRepeat: "repeat", backgroundPosition: "center", backgroundSize: cardBackgroundImageRepeat?.data.value || 500, opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1, }} > <CanvasBackgroundPattern pattern={canvasPattern} /> </div> );};
export const CanvasBackgroundPattern = (props: { pattern: "grid" | "dot" | "plain"; scale?: number;}) => { if (props.pattern === "plain") return null; let patternID = `canvasPattern-${props.pattern}-${props.scale}`; if (props.pattern === "grid") return ( <svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" className="pointer-events-none text-border-light" > <defs> <pattern id={patternID} x="0" y="0" width={props.scale ? 32 * props.scale : 32} height={props.scale ? 32 * props.scale : 32} viewBox={`${props.scale ? 16 * props.scale : 0} ${props.scale ? 16 * props.scale : 0} ${props.scale ? 32 * props.scale : 32} ${props.scale ? 32 * props.scale : 32}`} patternUnits="userSpaceOnUse" > <path fillRule="evenodd" clipRule="evenodd" d="M16.5 0H15.5L15.5 2.06061C15.5 2.33675 15.7239 2.56061 16 2.56061C16.2761 2.56061 16.5 2.33675 16.5 2.06061V0ZM0 16.5V15.5L2.06061 15.5C2.33675 15.5 2.56061 15.7239 2.56061 16C2.56061 16.2761 2.33675 16.5 2.06061 16.5L0 16.5ZM16.5 32H15.5V29.9394C15.5 29.6633 15.7239 29.4394 16 29.4394C16.2761 29.4394 16.5 29.6633 16.5 29.9394V32ZM32 15.5V16.5L29.9394 16.5C29.6633 16.5 29.4394 16.2761 29.4394 16C29.4394 15.7239 29.6633 15.5 29.9394 15.5H32ZM5.4394 16C5.4394 15.7239 5.66325 15.5 5.93939 15.5H10.0606C10.3367 15.5 10.5606 15.7239 10.5606 16C10.5606 16.2761 10.3368 16.5 10.0606 16.5H5.9394C5.66325 16.5 5.4394 16.2761 5.4394 16ZM13.4394 16C13.4394 15.7239 13.6633 15.5 13.9394 15.5H15.5V13.9394C15.5 13.6633 15.7239 13.4394 16 13.4394C16.2761 13.4394 16.5 13.6633 16.5 13.9394V15.5H18.0606C18.3367 15.5 18.5606 15.7239 18.5606 16C18.5606 16.2761 18.3367 16.5 18.0606 16.5H16.5V18.0606C16.5 18.3367 16.2761 18.5606 16 18.5606C15.7239 18.5606 15.5 18.3367 15.5 18.0606V16.5H13.9394C13.6633 16.5 13.4394 16.2761 13.4394 16ZM21.4394 16C21.4394 15.7239 21.6633 15.5 21.9394 15.5H26.0606C26.3367 15.5 26.5606 15.7239 26.5606 16C26.5606 16.2761 26.3367 16.5 26.0606 16.5H21.9394C21.6633 16.5 21.4394 16.2761 21.4394 16ZM16 5.4394C16.2761 5.4394 16.5 5.66325 16.5 5.93939V10.0606C16.5 10.3367 16.2761 10.5606 16 10.5606C15.7239 10.5606 15.5 10.3368 15.5 10.0606V5.9394C15.5 5.66325 15.7239 5.4394 16 5.4394ZM16 21.4394C16.2761 21.4394 16.5 21.6633 16.5 21.9394V26.0606C16.5 26.3367 16.2761 26.5606 16 26.5606C15.7239 26.5606 15.5 26.3367 15.5 26.0606V21.9394C15.5 21.6633 15.7239 21.4394 16 21.4394Z" fill="currentColor" /> </pattern> </defs> <rect width="100%" height="100%" x="0" y="0" fill={`url(#${patternID})`} /> </svg> );
if (props.pattern === "dot") { return ( <svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" className={`pointer-events-none text-border`} > <defs> <pattern id={patternID} x="0" y="0" width={props.scale ? 24 * props.scale : 24} height={props.scale ? 24 * props.scale : 24} patternUnits="userSpaceOnUse" > <circle cx={props.scale ? 12 * props.scale : 12} cy={props.scale ? 12 * props.scale : 12} r="1" fill="currentColor" /> </pattern> </defs> <rect width="100%" height="100%" x="0" y="0" fill={`url(#${patternID})`} /> </svg> ); }};
const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { return ( <div onMouseDown={props.onMouseDown} onPointerDown={props.onMouseDown} className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" > <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> {/* the gripper is two svg's stacked on top of each other. One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} <div className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} /> <div className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} /> </Media> </div> );};
type P = { x: number; y: number };function find_angle(P2: P, P1: P, P3: P) { if (P1.x === P3.x && P1.y === P3.y) return 0; let a = Math.atan2(P3.y - P1.y, P3.x - P1.x); let b = Math.atan2(P2.y - P1.y, P2.x - P1.x); return a - b;}