a tool for shared writing and social publishing

Feature/canvas (#61)

* test canvas

* add super basic canvas page type

* full width canvas page previews

* oops add back add block button

* make add block button cute

* add gripper to the canvas block

* add basic resizing

* styled resize handle!

* make backspace to delete a block work on canvases

and if you're the first block on a doc

* handle long press to select on canvas blocks

* handle spatial referencees in more places

* whoops didnt actually push anything last time?

* make enter in textblocks sorta work!

* make canvas height minheight

* don't show move block buttons on canvas

* deselect blocks if you click canvas bg

* fix canvas previews on home and bg in blocks

* share styles between canvas and doc page preview blocks

* hide grippers on preview

* add rotation to canvas elements

* add gripper bg

* sort canvas items to always visible

* make canvas bg theme based

* create new canvas item next to new item button

* add pagetype doc to blocks component

* replaced block options with block cmd bar

* add z-index to item when dragging

* tweaked gripper styles

* basic slash command stuff

* highlight first item of commands

* added canvas block icon, renamed doc page block icon, little style tweaks to the cmd menu

* translate then rotate on cnavas

* styled empty states for link and image blocks, added some keyboard stuff and selected styling to them

* fixed overflowing issue with externalLinkBlocks if the title is very long with no word breaks

* removed a lil weirdness in the svg for canvas icon

* tweak scroll behavior

* added a switcher toggle to pages. TODO, only show switcher if page is empty

* lil positioning fixes cause i borked up the stickiness of the toolbar

* idk man some notion of long pressing on mobile does stuff

* fix some hydration issues!

* don't show page toggle if page isn't empty

* double/ctrl/cmd click to add a card to canvas

* added canvas resizing

* fixed issue with block toolbar not closing

* add lil command button to text blocks

* fix positioning on enter for textblocks

* add are u sure state to canvas blocks

* added some padding to the drag handle, added padding to whole block (for better hovering

* added some styling to the / button

* tweaked empty state langauge

* persist canvas width toggle

* add migration to update all facts to support spatial ref

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
bc43f253 3dbb9888

+2033 -248
+106
app/canvastest/page.tsx
···
··· 1 + "use client"; 2 + import { useEffect, useState } from "react"; 3 + 4 + // NOTES 5 + // the canvas is min-w size of the page and max-w right most item until 1152px 6 + // the page is responsive up to max-w of 1152px 7 + 8 + // canvas is wider than the page, pan 9 + 10 + export default function CanvasTest() { 11 + let [height, setHeight] = useState<number | undefined>(undefined); 12 + 13 + useEffect(() => { 14 + setHeight(document.getElementById("canvasContent")?.scrollHeight); 15 + }, []); 16 + 17 + return ( 18 + <div 19 + className="leafletContentWrapper w-full h-full flex justify-center items-stretch bg-[#F0F7FA] py-8 " 20 + id="page-carousel" 21 + > 22 + <div 23 + onTouchMoveCapture={(e) => { 24 + e.preventDefault(); 25 + }} 26 + onWheelCapture={(e) => { 27 + e.preventDefault(); 28 + e.currentTarget.scrollLeft += e.deltaX; 29 + e.currentTarget.scrollTop += e.deltaY; 30 + }} 31 + className={` 32 + canvasWrapper relative mx-auto 33 + w-fit 34 + max-w-[calc(100vw-12px)] sm:max-w-[calc(100vw-128px)] lg:max-w-[1152px] 35 + bg-white rounded-lg border border-[#DBDBDB] 36 + overflow-y-scroll no-scrollbar 37 + `} 38 + > 39 + <div 40 + id="canvasContent" 41 + className={`canvasContent h-full w-[1150px] relative`} 42 + style={{ height: `calc(${height}px + 32px)` }} 43 + > 44 + <CanvasBackground color="#DBDBDB" /> 45 + <CanvasBlock w={200} h={150} x={34} y={48} r={-15} /> 46 + <CanvasBlock w={200} h={150} x={84} y={1248} r={15} /> 47 + <CanvasBlock w={200} h={150} x={900} y={436} r={27} /> 48 + <CanvasBlock w={200} h={150} x={583} y={827} r={-32} /> 49 + </div> 50 + </div> 51 + </div> 52 + ); 53 + } 54 + 55 + const CanvasBlock = (props: { 56 + w: number; 57 + h: number; 58 + x: number; 59 + y: number; 60 + r: number; 61 + }) => { 62 + return ( 63 + <div 64 + className="absolute bg-white rounded-lg border border-[#C3C3C3] p-2" 65 + style={{ 66 + width: `${props.w}px`, 67 + height: `${props.h}px`, 68 + top: `${props.y}px`, 69 + left: `${props.x}px`, 70 + transform: `rotate(${props.r}deg)`, 71 + }} 72 + > 73 + hi 74 + </div> 75 + ); 76 + }; 77 + 78 + const CanvasBackground = (props: { color: string }) => { 79 + return ( 80 + <svg 81 + width="100%" 82 + height="100%" 83 + xmlns="http://www.w3.org/2000/svg" 84 + className="pointer-events-none" 85 + > 86 + <defs> 87 + <pattern 88 + id="gridPattern" 89 + x="0" 90 + y="0" 91 + width="32" 92 + height="32" 93 + patternUnits="userSpaceOnUse" 94 + > 95 + <path 96 + fillRule="evenodd" 97 + clipRule="evenodd" 98 + 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" 99 + fill={props.color} 100 + /> 101 + </pattern> 102 + </defs> 103 + <rect width="100%" height="100%" x="0" y="0" fill="url(#gridPattern)" /> 104 + </svg> 105 + ); 106 + };
+3
app/globals.css
··· 21 --list-marker-width: 36px; 22 --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); 23 --page-width-units: min(624px, calc(100vw - 12px)); 24 } 25 @media (max-width: 640px) { 26 :root {
··· 21 --list-marker-width: 36px; 22 --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); 23 --page-width-units: min(624px, calc(100vw - 12px)); 24 + 25 + --gripperSVG: url("/gripperPattern.svg"); 26 + --gripperSVG2: url("/gripperPattern2.svg"); 27 } 28 @media (max-width: 640px) { 29 :root {
+22 -1
app/home/LeafletPreview.tsx
··· 7 import { useRef, useState } from "react"; 8 import { Link } from "react-aria-components"; 9 import { useBlocks } from "src/hooks/queries/useBlocks"; 10 - import { PermissionToken } from "src/replicache"; 11 import { deleteLeaflet } from "actions/deleteLeaflet"; 12 import { removeDocFromHome } from "./storage"; 13 import { mutate } from "swr"; 14 import useMeasure from "react-use-measure"; 15 import { ButtonPrimary } from "components/Buttons"; 16 import { LeafletOptions } from "./LeafletOptions"; 17 18 export const LeafletPreview = (props: { 19 token: PermissionToken; ··· 56 }; 57 58 const LeafletContent = (props: { entityID: string }) => { 59 let blocks = useBlocks(props.entityID); 60 let previewRef = useRef<HTMLDivElement | null>(null); 61 let [ref, dimensions] = useMeasure(); 62 63 return ( 64 <div 65 ref={previewRef} ··· 76 {blocks.slice(0, 10).map((b, index, arr) => { 77 return ( 78 <BlockPreview 79 entityID={b.value} 80 previousBlock={arr[index - 1] || null} 81 nextBlock={arr[index + 1] || null}
··· 7 import { useRef, useState } from "react"; 8 import { Link } from "react-aria-components"; 9 import { useBlocks } from "src/hooks/queries/useBlocks"; 10 + import { PermissionToken, useEntity } from "src/replicache"; 11 import { deleteLeaflet } from "actions/deleteLeaflet"; 12 import { removeDocFromHome } from "./storage"; 13 import { mutate } from "swr"; 14 import useMeasure from "react-use-measure"; 15 import { ButtonPrimary } from "components/Buttons"; 16 import { LeafletOptions } from "./LeafletOptions"; 17 + import { CanvasContent } from "components/Canvas"; 18 19 export const LeafletPreview = (props: { 20 token: PermissionToken; ··· 57 }; 58 59 const LeafletContent = (props: { entityID: string }) => { 60 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 61 let blocks = useBlocks(props.entityID); 62 let previewRef = useRef<HTMLDivElement | null>(null); 63 let [ref, dimensions] = useMeasure(); 64 65 + if (type === "canvas") 66 + return ( 67 + <div 68 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative bg-bg-page shadow-sm border border-border-light rounded-md`} 69 + > 70 + <div 71 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full h-full`} 72 + style={{ 73 + width: `1150px`, 74 + height: "calc(1150px * 2)", 75 + transform: `scale(calc((${dimensions.width} / 1150 )))`, 76 + }} 77 + > 78 + <CanvasContent entityID={props.entityID} preview /> 79 + </div> 80 + </div> 81 + ); 82 + 83 return ( 84 <div 85 ref={previewRef} ··· 96 {blocks.slice(0, 10).map((b, index, arr) => { 97 return ( 98 <BlockPreview 99 + pageType="doc" 100 entityID={b.value} 101 previousBlock={arr[index - 1] || null} 102 nextBlock={arr[index + 1] || null}
+3 -1
components/Blocks/Block.tsx
··· 31 }; 32 }; 33 export type BlockProps = { 34 entityID: string; 35 parent: string; 36 position: string; ··· 48 49 // focus block on longpress, shouldnt the type be based on the block type (?) 50 let { isLongPress, handlers } = useLongPress(() => { 51 if (isLongPress.current) { 52 focusBlock( 53 - { type: "card", value: props.entityID, parent: props.parent }, 54 { type: "start" }, 55 ); 56 }
··· 31 }; 32 }; 33 export type BlockProps = { 34 + pageType: Fact<"page/type">["data"]["value"]; 35 entityID: string; 36 parent: string; 37 position: string; ··· 49 50 // focus block on longpress, shouldnt the type be based on the block type (?) 51 let { isLongPress, handlers } = useLongPress(() => { 52 + console.log("wat"); 53 if (isLongPress.current) { 54 focusBlock( 55 + { type: props.type, value: props.entityID, parent: props.parent }, 56 { type: "start" }, 57 ); 58 }
+176
components/Blocks/BlockCommandBar.tsx
···
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import * as Popover from "@radix-ui/react-popover"; 3 + import { blockCommands } from "./BlockCommands"; 4 + import { useReplicache } from "src/replicache"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + 7 + type Props = { 8 + parent: string; 9 + entityID: string | null; 10 + position: string | null; 11 + nextPosition: string | null; 12 + factID?: string | undefined; 13 + first?: boolean; 14 + className?: string; 15 + }; 16 + 17 + export const BlockCommandBar = ({ 18 + props, 19 + searchValue, 20 + }: { 21 + props: Props; 22 + searchValue: string; 23 + }) => { 24 + let ref = useRef<HTMLDivElement>(null); 25 + 26 + let [highlighted, setHighlighted] = useState<string | undefined>(undefined); 27 + 28 + let { rep } = useReplicache(); 29 + let entity_set = useEntitySetContext(); 30 + 31 + let commandResults = blockCommands.filter((command) => 32 + command.name.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase()), 33 + ); 34 + useEffect(() => { 35 + if ( 36 + !highlighted || 37 + !commandResults.find((result) => result.name === highlighted) 38 + ) 39 + setHighlighted(commandResults[0]?.name); 40 + if (commandResults.length === 1) { 41 + setHighlighted(commandResults[0].name); 42 + } 43 + }, [commandResults, setHighlighted, highlighted]); 44 + useEffect(() => { 45 + let listener = async (e: KeyboardEvent) => { 46 + let input = document.getElementById("block-search"); 47 + let reverseDir = ref.current?.dataset.side === "top"; 48 + let currentHighlightIndex = commandResults.findIndex( 49 + (command: { name: string }) => 50 + highlighted && command.name === highlighted, 51 + ); 52 + 53 + if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { 54 + setHighlighted( 55 + commandResults[ 56 + currentHighlightIndex === commandResults.length - 1 || 57 + currentHighlightIndex === undefined 58 + ? 0 59 + : currentHighlightIndex + 1 60 + ].name, 61 + ); 62 + return; 63 + } 64 + if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { 65 + setHighlighted( 66 + commandResults[ 67 + currentHighlightIndex === 0 || 68 + currentHighlightIndex === undefined || 69 + currentHighlightIndex === -1 70 + ? commandResults.length - 1 71 + : currentHighlightIndex - 1 72 + ].name, 73 + ); 74 + return; 75 + } 76 + 77 + // on enter, select the highlighted item 78 + if (e.key === "Enter") { 79 + e.preventDefault(); 80 + rep && 81 + commandResults[currentHighlightIndex]?.onSelect(rep, { 82 + ...props, 83 + entity_set: entity_set.set, 84 + }); 85 + return; 86 + } 87 + 88 + // radix menu component handles esc 89 + if (e.key === "Escape") return; 90 + 91 + // any keypress that is not up down, left right, enter, esc, space focuses the search 92 + if (input) { 93 + input.focus(); 94 + } 95 + }; 96 + window.addEventListener("keydown", listener); 97 + 98 + return () => window.removeEventListener("keydown", listener); 99 + }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); 100 + 101 + return ( 102 + <Popover.Root open> 103 + <Popover.Trigger className="absolute left-0"></Popover.Trigger> 104 + <Popover.Portal> 105 + <Popover.Content 106 + align="start" 107 + sideOffset={16} 108 + collisionPadding={16} 109 + ref={ref} 110 + onOpenAutoFocus={(e) => e.preventDefault()} 111 + className={`commandMenuContent group/cmd-menu z-20 h-[292px] w-[264px] flex data-[side=top]:items-end items-start`} 112 + > 113 + <div className="commandMenuResults w-full flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page py-1 gap-0.5 border border-border rounded-md shadow-md"> 114 + {commandResults.length === 0 ? ( 115 + <div className="w-full text-tertiary text-center italic py-2 px-2 "> 116 + No blocks found 117 + </div> 118 + ) : ( 119 + commandResults.map((result, index) => ( 120 + <> 121 + <CommandResult 122 + key={index} 123 + name={result.name} 124 + icon={result.icon} 125 + onSelect={() => { 126 + rep && 127 + result.onSelect(rep, { 128 + ...props, 129 + entity_set: entity_set.set, 130 + }); 131 + }} 132 + highlighted={highlighted} 133 + setHighlighted={(highlighted) => 134 + setHighlighted(highlighted) 135 + } 136 + /> 137 + {commandResults[index + 1] && 138 + result.type !== commandResults[index + 1].type && ( 139 + <hr className="mx-2 my-0.5 border-border" /> 140 + )} 141 + </> 142 + )) 143 + )} 144 + </div> 145 + </Popover.Content> 146 + </Popover.Portal> 147 + </Popover.Root> 148 + ); 149 + }; 150 + 151 + const CommandResult = (props: { 152 + name: string; 153 + icon: React.ReactNode; 154 + onSelect: () => void; 155 + highlighted: string | undefined; 156 + setHighlighted: (state: string | undefined) => void; 157 + }) => { 158 + let isHighlighted = props.highlighted === props.name; 159 + 160 + return ( 161 + <button 162 + className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 163 + onMouseOver={() => { 164 + isHighlighted 165 + ? props.setHighlighted(undefined) 166 + : props.setHighlighted(props.name); 167 + }} 168 + onMouseDown={() => props.onSelect()} 169 + > 170 + <div className="text-tertiary w-8 shrink-0 flex justify-center"> 171 + {props.icon} 172 + </div> 173 + {props.name} 174 + </button> 175 + ); 176 + };
+231
components/Blocks/BlockCommands.tsx
···
··· 1 + import { Fact, ReplicacheMutators } from "src/replicache"; 2 + import { useUIState } from "src/useUIState"; 3 + import { 4 + BlockDocPageSmall, 5 + BlockCanvasPageSmall, 6 + BlockImageSmall, 7 + BlockLinkSmall, 8 + Header1Small, 9 + Header2Small, 10 + Header3Small, 11 + MailboxSmall, 12 + ParagraphSmall, 13 + } from "components/Icons"; 14 + import { generateKeyBetween } from "fractional-indexing"; 15 + import { focusPage } from "components/Pages"; 16 + import { v7 } from "uuid"; 17 + import { Replicache } from "replicache"; 18 + import { keepFocus } from "components/Toolbar/TextBlockTypeToolbar"; 19 + import { useEditorStates } from "src/state/useEditorState"; 20 + import { elementId } from "src/utils/elementId"; 21 + 22 + type Props = { 23 + parent: string; 24 + entityID: string | null; 25 + position: string | null; 26 + nextPosition: string | null; 27 + factID?: string | undefined; 28 + first?: boolean; 29 + className?: string; 30 + }; 31 + 32 + async function createBlockWithType( 33 + rep: Replicache<ReplicacheMutators>, 34 + args: { 35 + entity_set: string; 36 + parent: string; 37 + position: string | null; 38 + nextPosition: string | null; 39 + entityID: string | null; 40 + }, 41 + type: Fact<"block/type">["data"]["value"], 42 + ) { 43 + let entity; 44 + 45 + if (!args.entityID) { 46 + entity = v7(); 47 + await rep?.mutate.addBlock({ 48 + parent: args.parent, 49 + factID: v7(), 50 + permission_set: args.entity_set, 51 + type: type, 52 + position: generateKeyBetween(args.position, args.nextPosition), 53 + newEntityID: entity, 54 + }); 55 + } else { 56 + entity = args.entityID; 57 + await rep?.mutate.assertFact({ 58 + entity, 59 + attribute: "block/type", 60 + data: { type: "block-type-union", value: type }, 61 + }); 62 + } 63 + return entity; 64 + } 65 + 66 + function clearCommandSearchText(entityID: string) { 67 + useEditorStates.setState((s) => { 68 + let existingState = s.editorStates[entityID]; 69 + if (!existingState) { 70 + console.log("no existing state???"); 71 + return s; 72 + } 73 + 74 + let tr = existingState.editor.tr; 75 + console.log("deleting!"); 76 + tr.deleteRange(1, tr.doc.content.size - 1); 77 + return { 78 + editorStates: { 79 + ...s.editorStates, 80 + [entityID]: { 81 + ...existingState, 82 + editor: existingState.editor.apply(tr), 83 + }, 84 + }, 85 + }; 86 + }); 87 + } 88 + 89 + type Command = { 90 + name: string; 91 + icon: React.ReactNode; 92 + type: string; 93 + onSelect: ( 94 + rep: Replicache<ReplicacheMutators>, 95 + props: Props & { entity_set: string }, 96 + ) => void; 97 + }; 98 + export const blockCommands: Command[] = [ 99 + // please keep these in the order that they appear in the menu, grouped by type 100 + { 101 + name: "Text", 102 + icon: <ParagraphSmall />, 103 + type: "text", 104 + onSelect: async (rep, props) => { 105 + props.entityID && clearCommandSearchText(props.entityID); 106 + let entity = await createBlockWithType(rep, props, "text"); 107 + clearCommandSearchText(entity); 108 + keepFocus(entity); 109 + }, 110 + }, 111 + { 112 + name: "Title", 113 + icon: <Header1Small />, 114 + type: "text", 115 + onSelect: async (rep, props) => { 116 + props.entityID && clearCommandSearchText(props.entityID); 117 + let entity = await createBlockWithType(rep, props, "heading"); 118 + await rep.mutate.assertFact({ 119 + entity, 120 + attribute: "block/heading-level", 121 + data: { type: "number", value: 1 }, 122 + }); 123 + 124 + keepFocus(entity); 125 + }, 126 + }, 127 + { 128 + name: "Header", 129 + icon: <Header2Small />, 130 + type: "text", 131 + onSelect: async (rep, props) => { 132 + props.entityID && clearCommandSearchText(props.entityID); 133 + let entity = await createBlockWithType(rep, props, "heading"); 134 + rep.mutate.assertFact({ 135 + entity, 136 + attribute: "block/heading-level", 137 + data: { type: "number", value: 2 }, 138 + }); 139 + clearCommandSearchText(entity); 140 + keepFocus(entity); 141 + }, 142 + }, 143 + { 144 + name: "Subheader", 145 + icon: <Header3Small />, 146 + type: "text", 147 + onSelect: async (rep, props) => { 148 + props.entityID && clearCommandSearchText(props.entityID); 149 + let entity = await createBlockWithType(rep, props, "heading"); 150 + rep.mutate.assertFact({ 151 + entity, 152 + attribute: "block/heading-level", 153 + data: { type: "number", value: 3 }, 154 + }); 155 + clearCommandSearchText(entity); 156 + keepFocus(entity); 157 + }, 158 + }, 159 + 160 + { 161 + name: "External Link", 162 + icon: <BlockLinkSmall />, 163 + type: "block", 164 + onSelect: async (rep, props) => { 165 + createBlockWithType(rep, props, "link"); 166 + }, 167 + }, 168 + { 169 + name: "Image", 170 + icon: <BlockImageSmall />, 171 + type: "block", 172 + onSelect: async (rep, props) => { 173 + let entity = await createBlockWithType(rep, props, "image"); 174 + setTimeout(() => { 175 + let el = document.getElementById(elementId.block(entity).input); 176 + console.log(el); 177 + el?.focus(); 178 + }, 100); 179 + }, 180 + }, 181 + { 182 + name: "Mailbox", 183 + icon: <MailboxSmall />, 184 + type: "block", 185 + onSelect: async (rep, props) => { 186 + let entity; 187 + createBlockWithType(rep, props, "mailbox"); 188 + }, 189 + }, 190 + 191 + { 192 + name: "New Page", 193 + icon: <BlockDocPageSmall />, 194 + type: "page", 195 + onSelect: async (rep, props) => { 196 + let entity = await createBlockWithType(rep, props, "card"); 197 + 198 + let newPage = v7(); 199 + await rep?.mutate.addPageLinkBlock({ 200 + blockEntity: entity, 201 + firstBlockFactID: v7(), 202 + firstBlockEntity: v7(), 203 + pageEntity: newPage, 204 + type: "doc", 205 + permission_set: props.entity_set, 206 + }); 207 + useUIState.getState().openPage(props.parent, newPage); 208 + focusPage(newPage, rep, "focusFirstBlock"); 209 + }, 210 + }, 211 + { 212 + name: "New Canvas", 213 + icon: <BlockCanvasPageSmall />, 214 + type: "page", 215 + onSelect: async (rep, props) => { 216 + let entity = await createBlockWithType(rep, props, "card"); 217 + 218 + let newPage = v7(); 219 + await rep?.mutate.addPageLinkBlock({ 220 + type: "canvas", 221 + blockEntity: entity, 222 + firstBlockFactID: v7(), 223 + firstBlockEntity: v7(), 224 + pageEntity: newPage, 225 + permission_set: props.entity_set, 226 + }); 227 + useUIState.getState().openPage(props.parent, newPage); 228 + focusPage(newPage, rep, "focusFirstBlock"); 229 + }, 230 + }, 231 + ];
+4 -3
components/Blocks/BlockOptions.tsx
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 import { useUIState } from "src/useUIState"; 3 import { 4 - BlockPageLinkSmall, 5 BlockImageSmall, 6 BlockLinkSmall, 7 CheckTiny, ··· 34 35 type Props = { 36 parent: string; 37 - entityID: string | null; 38 position: string | null; 39 nextPosition: string | null; 40 factID?: string | undefined; ··· 139 } 140 let newPage = v7(); 141 await rep?.mutate.addPageLinkBlock({ 142 blockEntity: entity, 143 firstBlockFactID: v7(), 144 firstBlockEntity: v7(), ··· 149 if (rep) focusPage(newPage, rep, "focusFirstBlock"); 150 }} 151 > 152 - <BlockPageLinkSmall /> 153 </ToolbarButton> 154 <ToolbarButton 155 tooltipContent="Add a Link"
··· 1 import { useEntity, useReplicache } from "src/replicache"; 2 import { useUIState } from "src/useUIState"; 3 import { 4 + BlockDocPageSmall, 5 BlockImageSmall, 6 BlockLinkSmall, 7 CheckTiny, ··· 34 35 type Props = { 36 parent: string; 37 + entityID: string; 38 position: string | null; 39 nextPosition: string | null; 40 factID?: string | undefined; ··· 139 } 140 let newPage = v7(); 141 await rep?.mutate.addPageLinkBlock({ 142 + type: "doc", 143 blockEntity: entity, 144 firstBlockFactID: v7(), 145 firstBlockEntity: v7(), ··· 150 if (rep) focusPage(newPage, rep, "focusFirstBlock"); 151 }} 152 > 153 + <BlockDocPageSmall /> 154 </ToolbarButton> 155 <ToolbarButton 156 tooltipContent="Add a Link"
+136 -2
components/Blocks/ExternalLinkBlock.tsx
··· 1 - import { useEntity } from "src/replicache"; 2 import { useUIState } from "src/useUIState"; 3 4 - export const ExternalLinkBlock = (props: { entityID: string }) => { 5 let previewImage = useEntity(props.entityID, "link/preview"); 6 let title = useEntity(props.entityID, "link/title"); 7 let description = useEntity(props.entityID, "link/description"); ··· 10 let isSelected = useUIState((s) => 11 s.selectedBlocks.find((b) => b.value === props.entityID), 12 ); 13 14 return ( 15 <a ··· 26 <div className="flex flex-col w-full min-w-0 h-full grow "> 27 <div 28 className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-none resize-none align-top border h-[24px] line-clamp-1`} 29 > 30 {title?.data.value} 31 </div> ··· 54 </a> 55 ); 56 };
··· 1 + import { useEntitySetContext } from "components/EntitySetProvider"; 2 + import { generateKeyBetween } from "fractional-indexing"; 3 + import { useEffect, useState } from "react"; 4 + import { useEntity, useReplicache } from "src/replicache"; 5 import { useUIState } from "src/useUIState"; 6 + import { addLinkBlock } from "src/utils/addLinkBlock"; 7 + import { BlockProps } from "./Block"; 8 + import { v7 } from "uuid"; 9 + import { useSmoker } from "components/Toast"; 10 + import { BlockLinkSmall, CheckTiny } from "components/Icons"; 11 + import { Separator } from "components/Layout"; 12 + import { Input } from "components/Input"; 13 + import { isUrl } from "src/utils/isURL"; 14 + import { elementId } from "src/utils/elementId"; 15 + import { deleteBlock } from "./DeleteBlock"; 16 + import { focusBlock } from "src/utils/focusBlock"; 17 18 + export const ExternalLinkBlock = (props: BlockProps) => { 19 let previewImage = useEntity(props.entityID, "link/preview"); 20 let title = useEntity(props.entityID, "link/title"); 21 let description = useEntity(props.entityID, "link/description"); ··· 24 let isSelected = useUIState((s) => 25 s.selectedBlocks.find((b) => b.value === props.entityID), 26 ); 27 + useEffect(() => { 28 + let input = document.getElementById(elementId.block(props.entityID).input); 29 + if (isSelected) { 30 + input?.focus(); 31 + } else input?.blur(); 32 + }, [isSelected]); 33 + 34 + if (!url) { 35 + return ( 36 + <label 37 + id={elementId.block(props.entityID).input} 38 + className={`w-full h-[104px] text-tertiary hover:text-accent-contrast hover:cursor-pointer flex flex-auto gap-2 items-center justify-center p-2 ${isSelected ? "border-2 border-tertiary" : "border border-border"} hover:border-2 border-dashed rounded-lg`} 39 + onMouseDown={() => { 40 + focusBlock( 41 + { type: props.type, value: props.entityID, parent: props.parent }, 42 + { type: "start" }, 43 + ); 44 + }} 45 + > 46 + <BlockLinkInput {...props} /> 47 + </label> 48 + ); 49 + } 50 51 return ( 52 <a ··· 63 <div className="flex flex-col w-full min-w-0 h-full grow "> 64 <div 65 className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-none resize-none align-top border h-[24px] line-clamp-1`} 66 + style={{ 67 + overflow: "hidden", 68 + textOverflow: "ellipsis", 69 + wordBreak: "break-all", 70 + }} 71 > 72 {title?.data.value} 73 </div> ··· 96 </a> 97 ); 98 }; 99 + 100 + const BlockLinkInput = (props: BlockProps) => { 101 + let isSelected = useUIState((s) => 102 + s.selectedBlocks.find((b) => b.value === props.entityID), 103 + ); 104 + let entity_set = useEntitySetContext(); 105 + let [linkValue, setLinkValue] = useState(""); 106 + let { rep } = useReplicache(); 107 + let submit = async () => { 108 + let entity = props.entityID; 109 + if (!entity) { 110 + entity = v7(); 111 + 112 + await rep?.mutate.addBlock({ 113 + permission_set: entity_set.set, 114 + factID: v7(), 115 + parent: props.parent, 116 + type: "card", 117 + position: generateKeyBetween(props.position, props.nextPosition), 118 + newEntityID: entity, 119 + }); 120 + } 121 + let link = linkValue; 122 + if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 123 + addLinkBlock(link, entity, rep); 124 + }; 125 + let smoke = useSmoker(); 126 + 127 + return ( 128 + <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 129 + <> 130 + <BlockLinkSmall 131 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 132 + /> 133 + <Separator /> 134 + <Input 135 + type="url" 136 + className="w-full grow border-none outline-none bg-transparent " 137 + placeholder="www.example.com" 138 + value={linkValue} 139 + onChange={(e) => setLinkValue(e.target.value)} 140 + onKeyDown={(e) => { 141 + if (e.key === "Backspace" && linkValue === "") { 142 + rep && deleteBlock([props.entityID].flat(), rep); 143 + return; 144 + } 145 + if (e.key === "Enter") { 146 + if (!linkValue) return; 147 + if (!isUrl(linkValue)) { 148 + let rect = e.currentTarget.getBoundingClientRect(); 149 + smoke({ 150 + error: true, 151 + text: "invalid url!", 152 + position: { x: rect.left, y: rect.top - 8 }, 153 + }); 154 + return; 155 + } 156 + submit(); 157 + } 158 + }} 159 + /> 160 + <div className="flex items-center gap-3 "> 161 + <button 162 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 163 + onMouseDown={(e) => { 164 + e.preventDefault(); 165 + if (!linkValue || linkValue === "") { 166 + smoke({ 167 + error: true, 168 + text: "no url!", 169 + position: { x: e.clientX, y: e.clientY }, 170 + }); 171 + return; 172 + } 173 + if (!isUrl(linkValue)) { 174 + smoke({ 175 + error: true, 176 + text: "invalid url!", 177 + position: { x: e.clientX, y: e.clientY }, 178 + }); 179 + return; 180 + } 181 + submit(); 182 + }} 183 + > 184 + <CheckTiny /> 185 + </button> 186 + </div> 187 + </> 188 + </div> 189 + ); 190 + };
+75 -2
components/Blocks/ImageBlock.tsx
··· 1 "use client"; 2 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { Block } from "./Block"; 5 import { useUIState } from "src/useUIState"; 6 7 - export function ImageBlock(props: Block) { 8 let { rep } = useReplicache(); 9 let image = useEntity(props.value, "block/image"); 10 let isSelected = useUIState((s) => 11 s.selectedBlocks.find((b) => b.value === props.value), 12 ); 13 14 return ( 15 <div className="relative group/image flex w-full justify-center">
··· 1 "use client"; 2 3 import { useEntity, useReplicache } from "src/replicache"; 4 + import { Block, BlockProps } from "./Block"; 5 import { useUIState } from "src/useUIState"; 6 + import { BlockImageSmall } from "components/Icons"; 7 + import { v7 } from "uuid"; 8 + import { useEntitySetContext } from "components/EntitySetProvider"; 9 + import { generateKeyBetween } from "fractional-indexing"; 10 + import { addImage } from "src/utils/addImage"; 11 + import { elementId } from "src/utils/elementId"; 12 + import { useEffect } from "react"; 13 + import { deleteBlock } from "./DeleteBlock"; 14 15 + export function ImageBlock(props: BlockProps) { 16 let { rep } = useReplicache(); 17 let image = useEntity(props.value, "block/image"); 18 + let entity_set = useEntitySetContext(); 19 let isSelected = useUIState((s) => 20 s.selectedBlocks.find((b) => b.value === props.value), 21 ); 22 + useEffect(() => { 23 + let input = document.getElementById(elementId.block(props.entityID).input); 24 + if (isSelected) { 25 + input?.focus(); 26 + } else { 27 + input?.blur(); 28 + } 29 + }, [isSelected]); 30 + 31 + if (!image) { 32 + return ( 33 + <div className="grow w-full"> 34 + <label 35 + id={elementId.block(props.entityID).input} 36 + className={`group/image-block w-full h-[104px] text-tertiary hover:text-accent-contrast hover:font-bold hover:cursor-pointer flex flex-auto gap-2 items-center justify-center p-2 ${isSelected ? "border-2 border-tertiary font-bold" : "border border-border"} hover:border-2 border-dashed hover:border-accent-contrast rounded-lg`} 37 + onMouseDown={(e) => e.preventDefault()} 38 + onKeyDown={(e) => { 39 + if (e.key === "Backspace") { 40 + e.preventDefault(); 41 + rep && deleteBlock([props.entityID].flat(), rep); 42 + } 43 + }} 44 + > 45 + <BlockImageSmall 46 + className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} 47 + />{" "} 48 + Upload An Image 49 + <input 50 + className="h-0 w-0" 51 + type="file" 52 + accept="image/*" 53 + onChange={async (e) => { 54 + let file = e.currentTarget.files?.[0]; 55 + if (!file || !rep) return; 56 + let entity = props.entityID; 57 + if (!entity) { 58 + entity = v7(); 59 + await rep?.mutate.addBlock({ 60 + parent: props.parent, 61 + factID: v7(), 62 + permission_set: entity_set.set, 63 + type: "text", 64 + position: generateKeyBetween( 65 + props.position, 66 + props.nextPosition, 67 + ), 68 + newEntityID: entity, 69 + }); 70 + } 71 + await rep.mutate.assertFact({ 72 + entity, 73 + attribute: "block/type", 74 + data: { type: "block-type-union", value: "image" }, 75 + }); 76 + await addImage(file, rep, { 77 + entityID: entity, 78 + attribute: "block/image", 79 + }); 80 + }} 81 + /> 82 + </label> 83 + </div> 84 + ); 85 + } 86 87 return ( 88 <div className="relative group/image flex w-full justify-center">
+63 -9
components/Blocks/PageLinkBlock.tsx
··· 9 import { usePageMetadata } from "src/hooks/queries/usePageMetadata"; 10 import { CSSProperties, useEffect, useRef, useState } from "react"; 11 import { useBlocks } from "src/hooks/queries/useBlocks"; 12 13 export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 14 let { rep } = useReplicache(); 15 - let page = useEntity(props.entityID, "block/card"); 16 - let pageEntity = page ? page.data.value : props.entityID; 17 - let leafletMetadata = usePageMetadata(pageEntity); 18 19 let isSelected = useUIState((s) => 20 s.selectedBlocks.find((b) => b.value === props.entityID), 21 ); 22 23 - let isOpen = useUIState((s) => s.openPages).includes(pageEntity); 24 25 return ( 26 <div 27 - style={{ "--list-marker-width": "20px" } as CSSProperties} 28 - className={` 29 pageLinkBlockWrapper relative group/pageLinkBlock 30 - w-full h-[104px] 31 bg-bg-page border shadow-sm outline outline-1 rounded-lg 32 flex overflow-clip 33 ${ ··· 38 : "border-border-light outline-transparent hover:outline-border-light" 39 } 40 `} 41 > 42 <> 43 <div 44 - className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer" 45 onClick={(e) => { 46 if (e.isDefaultPrevented()) return; 47 if (e.shiftKey) return; ··· 117 {blocks.slice(0, 20).map((b, index, arr) => { 118 return ( 119 <BlockPreview 120 entityID={b.value} 121 previousBlock={arr[index - 1] || null} 122 nextBlock={arr[index + 1] || null} ··· 132 ); 133 } 134 135 export function BlockPreview( 136 b: BlockProps & { 137 previewRef: React.RefObject<HTMLDivElement>; 138 - size?: "small" | "large"; 139 }, 140 ) { 141 let ref = useRef<HTMLDivElement | null>(null);
··· 9 import { usePageMetadata } from "src/hooks/queries/usePageMetadata"; 10 import { CSSProperties, useEffect, useRef, useState } from "react"; 11 import { useBlocks } from "src/hooks/queries/useBlocks"; 12 + import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; 13 14 export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { 15 + let page = useEntity(props.entityID, "block/card"); 16 + let type = 17 + useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; 18 let { rep } = useReplicache(); 19 20 let isSelected = useUIState((s) => 21 s.selectedBlocks.find((b) => b.value === props.entityID), 22 ); 23 24 + let isOpen = useUIState((s) => s.openPages).includes(page?.data.value || ""); 25 26 return ( 27 <div 28 + className={`w-full cursor-pointer 29 pageLinkBlockWrapper relative group/pageLinkBlock 30 bg-bg-page border shadow-sm outline outline-1 rounded-lg 31 flex overflow-clip 32 ${ ··· 37 : "border-border-light outline-transparent hover:outline-border-light" 38 } 39 `} 40 + onClick={(e) => { 41 + if (!page) return; 42 + if (e.isDefaultPrevented()) return; 43 + if (e.shiftKey) return; 44 + e.preventDefault(); 45 + e.stopPropagation(); 46 + useUIState.getState().openPage(props.parent, page.data.value); 47 + if (rep) focusPage(page.data.value, rep); 48 + }} 49 + > 50 + {type === "canvas" && page ? ( 51 + <CanvasLinkBlock entityID={page?.data.value} /> 52 + ) : ( 53 + <DocLinkBlock {...props} /> 54 + )} 55 + </div> 56 + ); 57 + } 58 + export function DocLinkBlock(props: BlockProps & { preview?: boolean }) { 59 + let { rep } = useReplicache(); 60 + let page = useEntity(props.entityID, "block/card"); 61 + let pageEntity = page ? page.data.value : props.entityID; 62 + let leafletMetadata = usePageMetadata(pageEntity); 63 + 64 + return ( 65 + <div 66 + style={{ "--list-marker-width": "20px" } as CSSProperties} 67 + className={` 68 + w-full h-[104px] 69 + `} 70 > 71 <> 72 <div 73 + className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full" 74 onClick={(e) => { 75 if (e.isDefaultPrevented()) return; 76 if (e.shiftKey) return; ··· 146 {blocks.slice(0, 20).map((b, index, arr) => { 147 return ( 148 <BlockPreview 149 + pageType="doc" 150 entityID={b.value} 151 previousBlock={arr[index - 1] || null} 152 nextBlock={arr[index + 1] || null} ··· 162 ); 163 } 164 165 + const CanvasLinkBlock = (props: { entityID: string; preview?: boolean }) => { 166 + let pageWidth = `var(--page-width-unitless)`; 167 + return ( 168 + <div 169 + style={{ contain: "size layout paint" }} 170 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 171 + > 172 + <div 173 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 174 + style={{ 175 + width: `calc(1px * ${pageWidth})`, 176 + height: "calc(1150px * 2)", 177 + transform: `scale(calc((${pageWidth} / 1150 )))`, 178 + }} 179 + > 180 + {props.preview ? ( 181 + <CanvasBackground /> 182 + ) : ( 183 + <CanvasContent entityID={props.entityID} preview /> 184 + )} 185 + </div> 186 + </div> 187 + ); 188 + }; 189 + 190 export function BlockPreview( 191 b: BlockProps & { 192 previewRef: React.RefObject<HTMLDivElement>; 193 }, 194 ) { 195 let ref = useRef<HTMLDivElement | null>(null);
+43 -11
components/Blocks/TextBlock/index.tsx
··· 31 import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; 32 import { useAppEventListener } from "src/eventBus"; 33 import { addLinkBlock } from "src/utils/addLinkBlock"; 34 - import { BlockOptions } from "components/Blocks/BlockOptions"; 35 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 import { isIOS } from "@react-aria/utils"; 37 import { useIsMobile } from "src/hooks/isMobile"; ··· 41 import { useHandlePaste } from "./useHandlePaste"; 42 import { highlightSelectionPlugin } from "./plugins"; 43 import { inputrules } from "./inputRules"; 44 45 export function TextBlock( 46 props: BlockProps & { className: string; preview?: boolean }, ··· 148 149 export function BaseTextBlock(props: BlockProps & { className: string }) { 150 const [mount, setMount] = useState<HTMLElement | null>(null); 151 - 152 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 153 let entity_set = useEntitySetContext(); 154 let propsRef = useRef({ ...props, entity_set }); ··· 271 className={`${props.className} pointer-events-none absolute top-0 left-0 italic text-tertiary `} 272 > 273 {props.type === "text" 274 - ? "write something..." 275 : headingLevel?.data.value === 3 276 ? "Subheader" 277 : headingLevel?.data.value === 2 ··· 280 </div> 281 )} 282 {/* if this is the block is empty and selected */} 283 - {editorState.doc.textContent.length === 0 && selected && ( 284 - <BlockOptions 285 - factID={factID} 286 - entityID={props.entityID} 287 - parent={props.parent} 288 - position={props.position} 289 - nextPosition={props.nextPosition} 290 - first={first} 291 /> 292 )} 293 </div>
··· 31 import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; 32 import { useAppEventListener } from "src/eventBus"; 33 import { addLinkBlock } from "src/utils/addLinkBlock"; 34 + import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 35 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 import { isIOS } from "@react-aria/utils"; 37 import { useIsMobile } from "src/hooks/isMobile"; ··· 41 import { useHandlePaste } from "./useHandlePaste"; 42 import { highlightSelectionPlugin } from "./plugins"; 43 import { inputrules } from "./inputRules"; 44 + import { MoreOptionsTiny } from "components/Icons"; 45 46 export function TextBlock( 47 props: BlockProps & { className: string; preview?: boolean }, ··· 149 150 export function BaseTextBlock(props: BlockProps & { className: string }) { 151 const [mount, setMount] = useState<HTMLElement | null>(null); 152 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 153 let entity_set = useEntitySetContext(); 154 let propsRef = useRef({ ...props, entity_set }); ··· 271 className={`${props.className} pointer-events-none absolute top-0 left-0 italic text-tertiary `} 272 > 273 {props.type === "text" 274 + ? 'write something... or type "/"' 275 : headingLevel?.data.value === 3 276 ? "Subheader" 277 : headingLevel?.data.value === 2 ··· 280 </div> 281 )} 282 {/* if this is the block is empty and selected */} 283 + {editorState.doc.textContent.length === 0 && selected ? ( 284 + <button 285 + className="absolute top-0.5 right-0 w-5 h-5 rounded border border-border outline outline-transparent hover:outline-border hover:text-tertiary font-bold rounded-md text-sm text-border" 286 + onMouseDown={(e) => { 287 + e.preventDefault(); 288 + console.log("yo!"); 289 + let editor = 290 + useEditorStates.getState().editorStates[props.entityID]; 291 + 292 + let editorState = editor?.editor; 293 + if (editorState) { 294 + editor?.view?.focus(); 295 + let tr = editorState.tr.insertText("/", 1); 296 + tr.setSelection(TextSelection.create(tr.doc, 2)); 297 + console.log(tr); 298 + useEditorStates.setState((s) => ({ 299 + editorStates: { 300 + ...s.editorStates, 301 + [props.entityID]: { 302 + ...s.editorStates[props.entityID]!, 303 + editor: editorState!.apply(tr), 304 + }, 305 + }, 306 + })); 307 + } 308 + }} 309 + > 310 + <div className="-mt-[2px]">/</div> 311 + </button> 312 + ) : null} 313 + {editorState.doc.textContent.startsWith("/") && selected && ( 314 + <BlockCommandBar 315 + props={props} 316 + searchValue={editorState.doc.textContent.slice(1)} 317 + /> 318 + )} 319 + {editorState.doc.textContent.startsWith("/") && selected && ( 320 + <BlockCommandBar 321 + props={props} 322 + searchValue={editorState.doc.textContent.slice(1)} 323 /> 324 )} 325 </div>
+43 -8
components/Blocks/TextBlock/keymap.ts
··· 17 import { scanIndex } from "src/replicache/utils"; 18 import { indent, outdent } from "src/utils/list-operations"; 19 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 20 21 type PropsRef = MutableRefObject<BlockProps & { entity_set: { set: string } }>; 22 export const TextBlockKeymap = ( ··· 101 }, 102 ArrowUp: (state, _tr, view) => { 103 if (!view) return false; 104 if (useUIState.getState().selectedBlocks.length > 1) return true; 105 if (view.state.selection.from !== view.state.selection.to) return false; 106 const viewClientRect = view.dom.getBoundingClientRect(); ··· 118 }, 119 ArrowDown: (state, tr, view) => { 120 if (!view) return true; 121 if (useUIState.getState().selectedBlocks.length > 1) return true; 122 if (view.state.selection.from !== view.state.selection.to) return false; 123 const viewClientRect = view.dom.getBoundingClientRect(); ··· 232 ), 233 10, 234 ); 235 } 236 - return false; 237 } 238 239 - let block = 240 - useEditorStates.getState().editorStates[ 241 - propsRef.current.previousBlock.value 242 - ]; 243 if ( 244 block && 245 block.editor.doc.textContent.length === 0 && 246 - !propsRef.current.previousBlock.listData 247 ) { 248 repRef.current?.mutate.removeBlock({ 249 blockEntity: propsRef.current.previousBlock.value, ··· 261 return true; 262 } 263 264 - if (propsRef.current.previousBlock.type === "card") { 265 focusBlock(propsRef.current.previousBlock, { type: "end" }); 266 view?.dom.blur(); 267 return true; 268 } 269 270 - if (!block) return false; 271 272 repRef.current?.mutate.removeBlock({ 273 blockEntity: propsRef.current.entityID, ··· 318 dispatch?: (tr: Transaction) => void, 319 view?: EditorView, 320 ) => { 321 let tr = state.tr; 322 let newContent = tr.doc.slice(state.selection.anchor); 323 tr.delete(state.selection.anchor, state.doc.content.size); ··· 330 propsRef.current.type === "heading" && state.selection.anchor <= 2 331 ? ("heading" as const) 332 : ("text" as const); 333 if (propsRef.current.listData) { 334 if (state.doc.content.size <= 2) { 335 return shifttab(propsRef, repRef)();
··· 17 import { scanIndex } from "src/replicache/utils"; 18 import { indent, outdent } from "src/utils/list-operations"; 19 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 20 + import { isTextBlock } from "src/utils/isTextBlock"; 21 22 type PropsRef = MutableRefObject<BlockProps & { entity_set: { set: string } }>; 23 export const TextBlockKeymap = ( ··· 102 }, 103 ArrowUp: (state, _tr, view) => { 104 if (!view) return false; 105 + if (state.doc.textContent.startsWith("/")) return true; 106 if (useUIState.getState().selectedBlocks.length > 1) return true; 107 if (view.state.selection.from !== view.state.selection.to) return false; 108 const viewClientRect = view.dom.getBoundingClientRect(); ··· 120 }, 121 ArrowDown: (state, tr, view) => { 122 if (!view) return true; 123 + if (state.doc.textContent.startsWith("/")) return true; 124 if (useUIState.getState().selectedBlocks.length > 1) return true; 125 if (view.state.selection.from !== view.state.selection.to) return false; 126 const viewClientRect = view.dom.getBoundingClientRect(); ··· 235 ), 236 10, 237 ); 238 + 239 + return false; 240 } 241 } 242 243 + let block = !!propsRef.current.previousBlock 244 + ? useEditorStates.getState().editorStates[ 245 + propsRef.current.previousBlock.value 246 + ] 247 + : null; 248 if ( 249 block && 250 + propsRef.current.previousBlock && 251 block.editor.doc.textContent.length === 0 && 252 + !propsRef.current.previousBlock?.listData 253 ) { 254 repRef.current?.mutate.removeBlock({ 255 blockEntity: propsRef.current.previousBlock.value, ··· 267 return true; 268 } 269 270 + if ( 271 + propsRef.current.previousBlock && 272 + !isTextBlock[propsRef.current.previousBlock?.type] 273 + ) { 274 focusBlock(propsRef.current.previousBlock, { type: "end" }); 275 view?.dom.blur(); 276 return true; 277 } 278 279 + if (!block || !propsRef.current.previousBlock) return false; 280 281 repRef.current?.mutate.removeBlock({ 282 blockEntity: propsRef.current.entityID, ··· 327 dispatch?: (tr: Transaction) => void, 328 view?: EditorView, 329 ) => { 330 + if (state.doc.textContent.startsWith("/")) return true; 331 let tr = state.tr; 332 let newContent = tr.doc.slice(state.selection.anchor); 333 tr.delete(state.selection.anchor, state.doc.content.size); ··· 340 propsRef.current.type === "heading" && state.selection.anchor <= 2 341 ? ("heading" as const) 342 : ("text" as const); 343 + if (propsRef.current.pageType === "canvas") { 344 + let el = document.getElementById( 345 + elementId.block(propsRef.current.entityID).container, 346 + ); 347 + let [position] = 348 + (await repRef.current?.query((tx) => 349 + scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"), 350 + )) || []; 351 + if (!position || !el) return; 352 + 353 + let box = el.getBoundingClientRect(); 354 + 355 + await repRef.current?.mutate.addCanvasBlock({ 356 + newEntityID, 357 + factID: v7(), 358 + permission_set: propsRef.current.entity_set.set, 359 + parent: propsRef.current.parent, 360 + type: blockType, 361 + position: { 362 + x: position.data.position.x, 363 + y: position.data.position.y + box.height + 12, 364 + }, 365 + }); 366 + return; 367 + } 368 if (propsRef.current.listData) { 369 if (state.doc.content.size <= 2) { 370 return shifttab(propsRef, repRef)();
+11 -17
components/Blocks/index.tsx
··· 72 }, [blocks]); 73 74 let lastRootBlock = blocks.findLast( 75 - (f) => !f.listData || f.listData.depth === 1 76 ); 77 78 let lastVisibleBlock = blocks.findLast( 79 (f) => 80 !f.listData || 81 !f.listData.path.find( 82 - (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity 83 - ) 84 ); 85 86 return ( ··· 102 type: "text", 103 position: generateKeyBetween( 104 lastRootBlock?.position || null, 105 - null 106 ), 107 newEntityID, 108 }); ··· 124 !f.listData || 125 !f.listData.path.find( 126 (path) => 127 - foldedBlocks.includes(path.entity) && f.value !== path.entity 128 - ) 129 ) 130 .map((f, index, arr) => { 131 let nextBlock = arr[index + 1]; ··· 140 } 141 return ( 142 <Block 143 {...f} 144 key={f.value} 145 entityID={f.value} ··· 170 let editorState = useEditorStates((s) => 171 props.lastBlock?.type === "text" 172 ? s.editorStates[props.lastBlock.value] 173 - : null 174 ); 175 176 if (!entity_set.permissions.write) return null; ··· 192 permission_set: entity_set.set, 193 position: generateKeyBetween( 194 props.lastBlock?.position || null, 195 - null 196 ), 197 newEntityID, 198 }); ··· 210 " " 211 )} 212 </div> 213 - <BlockOptions 214 - parent={props.entityID} 215 - entityID={null} 216 - position={props.lastBlock?.position || null} 217 - nextPosition={null} 218 - first={!props.lastBlock} 219 - /> 220 </div> 221 ); 222 } ··· 243 ) { 244 focusBlock( 245 { ...props.lastVisibleBlock, type: "text" }, 246 - { type: "end" } 247 ); 248 } else { 249 // else add a new text block at the end and focus it ··· 254 type: "text", 255 position: generateKeyBetween( 256 props.lastRootBlock?.position || null, 257 - null 258 ), 259 newEntityID, 260 });
··· 72 }, [blocks]); 73 74 let lastRootBlock = blocks.findLast( 75 + (f) => !f.listData || f.listData.depth === 1, 76 ); 77 78 let lastVisibleBlock = blocks.findLast( 79 (f) => 80 !f.listData || 81 !f.listData.path.find( 82 + (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, 83 + ), 84 ); 85 86 return ( ··· 102 type: "text", 103 position: generateKeyBetween( 104 lastRootBlock?.position || null, 105 + null, 106 ), 107 newEntityID, 108 }); ··· 124 !f.listData || 125 !f.listData.path.find( 126 (path) => 127 + foldedBlocks.includes(path.entity) && f.value !== path.entity, 128 + ), 129 ) 130 .map((f, index, arr) => { 131 let nextBlock = arr[index + 1]; ··· 140 } 141 return ( 142 <Block 143 + pageType="doc" 144 {...f} 145 key={f.value} 146 entityID={f.value} ··· 171 let editorState = useEditorStates((s) => 172 props.lastBlock?.type === "text" 173 ? s.editorStates[props.lastBlock.value] 174 + : null, 175 ); 176 177 if (!entity_set.permissions.write) return null; ··· 193 permission_set: entity_set.set, 194 position: generateKeyBetween( 195 props.lastBlock?.position || null, 196 + null, 197 ), 198 newEntityID, 199 }); ··· 211 " " 212 )} 213 </div> 214 </div> 215 ); 216 } ··· 237 ) { 238 focusBlock( 239 { ...props.lastVisibleBlock, type: "text" }, 240 + { type: "end" }, 241 ); 242 } else { 243 // else add a new text block at the end and focus it ··· 248 type: "text", 249 position: generateKeyBetween( 250 props.lastRootBlock?.position || null, 251 + null, 252 ), 253 newEntityID, 254 });
+18 -10
components/Blocks/useBlockKeyboardHandlers.ts
··· 95 async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 96 // if this is a textBlock, let the textBlock/keymap handle the backspace 97 if (isTextBlock[props.type]) return; 98 99 // if the block is a card or mailbox... 100 if (props.type === "card" || props.type === "mailbox") { ··· 107 // and the user is not in an input or textarea, 108 // if there is a page to close, close it and remove the block 109 if (areYouSure) { 110 - let el = e.target as HTMLElement; 111 - 112 - if ( 113 - el.tagName === "INPUT" || 114 - el.tagName === "textarea" || 115 - el.contentEditable === "true" 116 - ) 117 - return; 118 - 119 return deleteBlock([props.entityID].flat(), rep); 120 } 121 } ··· 127 if (prevBlock) focusBlock(prevBlock, { type: "end" }); 128 } 129 130 - async function Enter({ props, rep, entity_set }: Args) { 131 let newEntityID = v7(); 132 let position; 133 // if it's a list, create a new list item at the same depth 134 if (props.listData) { 135 let hasChild =
··· 95 async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 96 // if this is a textBlock, let the textBlock/keymap handle the backspace 97 if (isTextBlock[props.type]) return; 98 + let el = e.target as HTMLElement; 99 + if ( 100 + el.tagName === "LABEL" || 101 + el.tagName === "INPUT" || 102 + el.tagName === "TEXTAREA" || 103 + el.contentEditable === "true" 104 + ) 105 + return; 106 107 // if the block is a card or mailbox... 108 if (props.type === "card" || props.type === "mailbox") { ··· 115 // and the user is not in an input or textarea, 116 // if there is a page to close, close it and remove the block 117 if (areYouSure) { 118 return deleteBlock([props.entityID].flat(), rep); 119 } 120 } ··· 126 if (prevBlock) focusBlock(prevBlock, { type: "end" }); 127 } 128 129 + async function Enter({ e, props, rep, entity_set }: Args) { 130 let newEntityID = v7(); 131 let position; 132 + let el = e.target as HTMLElement; 133 + if ( 134 + el.tagName === "LABEL" || 135 + el.tagName === "INPUT" || 136 + el.tagName === "TEXTAREA" || 137 + el.contentEditable === "true" 138 + ) 139 + return; 140 + 141 // if it's a list, create a new list item at the same depth 142 if (props.listData) { 143 let hasChild =
+51
components/Buttons.tsx
··· 1 import React from "react"; 2 3 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 4 export function ButtonPrimary( ··· 55 </div> 56 ); 57 };
··· 1 import React from "react"; 2 + import * as RadixTooltip from "@radix-ui/react-tooltip"; 3 + import { theme } from "tailwind.config"; 4 + import { PopoverArrow } from "./Icons"; 5 6 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 7 export function ButtonPrimary( ··· 58 </div> 59 ); 60 }; 61 + 62 + export const TooltipButton = (props: { 63 + onMouseDown?: (e: React.MouseEvent) => void; 64 + className?: string; 65 + children: React.ReactNode; 66 + content: React.ReactNode; 67 + side?: "top" | "right" | "bottom" | "left" | undefined; 68 + }) => { 69 + return ( 70 + // toolbar button does not control the highlight theme setter 71 + // if toolbar button is updated, be sure to update there as well 72 + <RadixTooltip.TooltipProvider> 73 + <RadixTooltip.Root> 74 + <RadixTooltip.Trigger 75 + className={props.className} 76 + onMouseDown={(e) => { 77 + e.preventDefault(); 78 + props.onMouseDown && props.onMouseDown(e); 79 + }} 80 + > 81 + {props.children} 82 + </RadixTooltip.Trigger> 83 + 84 + <RadixTooltip.Portal> 85 + <RadixTooltip.Content 86 + side={props.side ? props.side : undefined} 87 + sideOffset={6} 88 + alignOffset={12} 89 + className="z-10 bg-border rounded-md py-1 px-[6px] font-bold text-secondary text-sm" 90 + > 91 + {props.content} 92 + <RadixTooltip.Arrow 93 + asChild 94 + width={16} 95 + height={8} 96 + viewBox="0 0 16 8" 97 + > 98 + <PopoverArrow 99 + arrowFill={theme.colors["border"]} 100 + arrowStroke="transparent" 101 + /> 102 + </RadixTooltip.Arrow> 103 + </RadixTooltip.Content> 104 + </RadixTooltip.Portal> 105 + </RadixTooltip.Root> 106 + </RadixTooltip.TooltipProvider> 107 + ); 108 + };
+450
components/Canvas.tsx
···
··· 1 + import { useEntity, useReplicache } from "src/replicache"; 2 + import { useEntitySetContext } from "./EntitySetProvider"; 3 + import { v7 } from "uuid"; 4 + import { BaseBlock, Block } from "./Blocks/Block"; 5 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 6 + import { 7 + AddBlockLarge, 8 + AddSmall, 9 + CanvasShrinkSmall, 10 + CanvasWidenSmall, 11 + } from "./Icons"; 12 + import { useDrag } from "src/hooks/useDrag"; 13 + import { useLongPress } from "src/hooks/useLongPress"; 14 + import { focusBlock } from "src/utils/focusBlock"; 15 + import { elementId } from "src/utils/elementId"; 16 + import { useUIState } from "src/useUIState"; 17 + import useMeasure from "react-use-measure"; 18 + import { useIsMobile } from "src/hooks/isMobile"; 19 + import { Media } from "./Media"; 20 + import { TooltipButton } from "./Buttons"; 21 + import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 22 + 23 + export function Canvas(props: { entityID: string; preview?: boolean }) { 24 + let entity_set = useEntitySetContext(); 25 + let ref = useRef<HTMLDivElement>(null); 26 + useEffect(() => { 27 + let abort = new AbortController(); 28 + let isTouch = false; 29 + let startX: number, startY: number, scrollLeft: number, scrollTop: number; 30 + let el = ref.current; 31 + ref.current?.addEventListener( 32 + "wheel", 33 + (e) => { 34 + e.preventDefault(); 35 + if (!el) return; 36 + el.scrollLeft += e.deltaX; 37 + el.scrollTop += e.deltaY; 38 + }, 39 + { passive: false, signal: abort.signal }, 40 + ); 41 + return () => abort.abort(); 42 + }); 43 + 44 + let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 45 + .value; 46 + 47 + return ( 48 + <div 49 + ref={ref} 50 + id={elementId.page(props.entityID).canvasScrollArea} 51 + className={` 52 + canvasWrapper 53 + h-full w-fit mx-auto 54 + max-w-[calc(100vw-12px)] 55 + ${!narrowWidth ? "sm:max-w-[calc(100vw-128px)] lg:max-w-[calc(var(--page-width-units)*2 + 24px))]" : " sm:max-w-[var(--page-width-units)]"} 56 + bg-bg-page rounded-lg 57 + overflow-y-scroll no-scrollbar 58 + `} 59 + > 60 + <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 61 + <CanvasContent {...props} /> 62 + </div> 63 + ); 64 + } 65 + 66 + export function CanvasContent(props: { entityID: string; preview?: boolean }) { 67 + let blocks = useEntity(props.entityID, "canvas/block"); 68 + let { rep } = useReplicache(); 69 + let entity_set = useEntitySetContext(); 70 + let [height, setHeight] = useState<number | undefined>(undefined); 71 + useEffect(() => { 72 + setHeight( 73 + document.getElementById(elementId.page(props.entityID).canvasScrollArea) 74 + ?.scrollHeight, 75 + ); 76 + }, [blocks, props.entityID]); 77 + return ( 78 + <div 79 + onClick={async (e) => { 80 + if (e.currentTarget !== e.target) return; 81 + useUIState.setState(() => ({ 82 + selectedBlocks: [], 83 + })); 84 + if (e.detail === 2 || e.ctrlKey || e.metaKey) { 85 + let parentRect = e.currentTarget.getBoundingClientRect(); 86 + let newEntityID = v7(); 87 + await rep?.mutate.addCanvasBlock({ 88 + newEntityID, 89 + parent: props.entityID, 90 + position: { 91 + x: Math.max(e.clientX - parentRect.left, 0), 92 + y: Math.max(e.clientY - parentRect.top - 12, 0), 93 + }, 94 + factID: v7(), 95 + type: "text", 96 + permission_set: entity_set.set, 97 + }); 98 + focusBlock( 99 + { type: "text", parent: props.entityID, value: newEntityID }, 100 + { type: "start" }, 101 + ); 102 + } 103 + }} 104 + style={{ 105 + minHeight: `calc(${height}px + 32px)`, 106 + contain: "size layout paint", 107 + }} 108 + className="relative h-full w-[1272px]" 109 + > 110 + <CanvasBackground /> 111 + {blocks 112 + .sort((a, b) => { 113 + if (a.data.position.y === b.data.position.y) { 114 + return a.data.position.x - b.data.position.x; 115 + } 116 + return a.data.position.y - b.data.position.y; 117 + }) 118 + .map((b) => { 119 + return ( 120 + <CanvasBlock 121 + preview={props.preview} 122 + parent={props.entityID} 123 + entityID={b.data.value} 124 + position={b.data.position} 125 + factID={b.id} 126 + key={b.id} 127 + /> 128 + ); 129 + })} 130 + </div> 131 + ); 132 + } 133 + 134 + const AddCanvasBlockButton = (props: { 135 + entityID: string; 136 + entity_set: { set: string }; 137 + }) => { 138 + let { rep } = useReplicache(); 139 + let narrowWidth = useEntity(props.entityID, "canvas/narrow-width")?.data 140 + .value; 141 + return ( 142 + <div className="absolute right-2 sm:top-4 sm:right-4 bottom-2 sm:bottom-auto z-10 flex flex-col gap-1 justify-center"> 143 + <TooltipButton 144 + side="left" 145 + content={ 146 + <div className="flex flex-col justify-end text-center "> 147 + <div>Add a Block!</div> 148 + <div className="font-normal">or double click anywhere</div> 149 + </div> 150 + } 151 + className="w-fit p-2 rounded-full bg-accent-1 border-2 outline outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-2" 152 + onMouseDown={() => { 153 + let page = document.getElementById( 154 + elementId.page(props.entityID).canvasScrollArea, 155 + ); 156 + if (!page) return; 157 + let newEntityID = v7(); 158 + rep?.mutate.addCanvasBlock({ 159 + newEntityID, 160 + parent: props.entityID, 161 + position: { 162 + x: page?.clientWidth + page?.scrollLeft - 468, 163 + y: 32 + page.scrollTop, 164 + }, 165 + factID: v7(), 166 + type: "text", 167 + permission_set: props.entity_set.set, 168 + }); 169 + setTimeout(() => { 170 + focusBlock( 171 + { type: "text", value: newEntityID, parent: props.entityID }, 172 + { type: "start" }, 173 + ); 174 + }, 20); 175 + }} 176 + > 177 + <AddSmall /> 178 + </TooltipButton> 179 + 180 + <TooltipButton 181 + side="left" 182 + onMouseDown={() => { 183 + rep?.mutate.assertFact({ 184 + entity: props.entityID, 185 + attribute: "canvas/narrow-width", 186 + data: { type: "boolean", value: !narrowWidth }, 187 + }); 188 + }} 189 + content={narrowWidth ? "Widen Canvas" : "Narrow Canvas"} 190 + className="hidden sm:block bg-accent-2 border-2 outline outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-1 p-1 rounded-full w-fit mx-auto" 191 + > 192 + {narrowWidth ? <CanvasWidenSmall /> : <CanvasShrinkSmall />} 193 + </TooltipButton> 194 + </div> 195 + ); 196 + }; 197 + 198 + function CanvasBlock(props: { 199 + preview?: boolean; 200 + entityID: string; 201 + parent: string; 202 + position: { x: number; y: number }; 203 + factID: string; 204 + }) { 205 + let width = 206 + useEntity(props.entityID, "canvas/block/width")?.data.value || 360; 207 + let rotation = 208 + useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0; 209 + let [ref, rect] = useMeasure(); 210 + let type = useEntity(props.entityID, "block/type"); 211 + let { rep } = useReplicache(); 212 + let isMobile = useIsMobile(); 213 + 214 + let onDragEnd = useCallback( 215 + (dragPosition: { x: number; y: number }) => { 216 + rep?.mutate.assertFact({ 217 + id: props.factID, 218 + entity: props.parent, 219 + attribute: "canvas/block", 220 + data: { 221 + type: "spatial-reference", 222 + value: props.entityID, 223 + position: { 224 + x: props.position.x + dragPosition.x, 225 + y: props.position.y + dragPosition.y, 226 + }, 227 + }, 228 + }); 229 + }, 230 + [props, rep], 231 + ); 232 + let { dragDelta, handlers } = useDrag({ 233 + onDragEnd, 234 + delay: isMobile, 235 + }); 236 + 237 + let widthOnDragEnd = useCallback( 238 + (dragPosition: { x: number; y: number }) => { 239 + console.log(dragPosition, rep); 240 + rep?.mutate.assertFact({ 241 + entity: props.entityID, 242 + attribute: "canvas/block/width", 243 + data: { 244 + type: "number", 245 + value: width + dragPosition.x, 246 + }, 247 + }); 248 + }, 249 + [props, rep, width], 250 + ); 251 + let widthHandle = useDrag({ onDragEnd: widthOnDragEnd }); 252 + 253 + let RotateOnDragEnd = useCallback( 254 + (dragDelta: { x: number; y: number }) => { 255 + let originX = rect.x + rect.width / 2; 256 + let originY = rect.y + rect.height / 2; 257 + 258 + let angle = 259 + find_angle( 260 + { x: rect.x + rect.width, y: rect.y + rect.height }, 261 + { x: originX, y: originY }, 262 + { 263 + x: rect.x + rect.width + dragDelta.x, 264 + y: rect.y + rect.height + dragDelta.y, 265 + }, 266 + ) * 267 + (180 / Math.PI); 268 + 269 + rep?.mutate.assertFact({ 270 + entity: props.entityID, 271 + attribute: "canvas/block/rotation", 272 + data: { 273 + type: "number", 274 + value: (rotation + angle) % 360, 275 + }, 276 + }); 277 + }, 278 + [props, rep, rect, rotation], 279 + ); 280 + let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 281 + 282 + let { isLongPress, handlers: longPressHandlers } = useLongPress( 283 + () => { 284 + if (isLongPress.current) { 285 + focusBlock( 286 + { 287 + type: type?.data.value || "text", 288 + value: props.entityID, 289 + parent: props.parent, 290 + }, 291 + { type: "start" }, 292 + ); 293 + } 294 + }, 295 + () => {}, 296 + ); 297 + let angle = 0; 298 + if (rotateHandle.dragDelta) { 299 + let originX = rect.x + rect.width / 2; 300 + let originY = rect.y + rect.height / 2; 301 + 302 + angle = 303 + find_angle( 304 + { x: rect.x + rect.width, y: rect.y + rect.height }, 305 + { x: originX, y: originY }, 306 + { 307 + x: rect.x + rect.width + rotateHandle.dragDelta.x, 308 + y: rect.y + rect.height + rotateHandle.dragDelta.y, 309 + }, 310 + ) * 311 + (180 / Math.PI); 312 + } 313 + let x = props.position.x + (dragDelta?.x || 0); 314 + let y = props.position.y + (dragDelta?.y || 0); 315 + let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; 316 + let [areYouSure, setAreYouSure] = useState(false); 317 + let blockProps = useMemo(() => { 318 + return { 319 + pageType: "canvas" as const, 320 + preview: props.preview, 321 + type: type?.data.value || "text", 322 + value: props.entityID, 323 + factID: props.factID, 324 + position: "", 325 + nextPosition: "", 326 + entityID: props.entityID, 327 + parent: props.parent, 328 + nextBlock: null, 329 + previousBlock: null, 330 + }; 331 + }, [props, type?.data.value]); 332 + useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 333 + let isList = useEntity(props.entityID, "block/is-list"); 334 + 335 + return ( 336 + <div 337 + ref={ref} 338 + {...(!props.preview ? { ...longPressHandlers } : {})} 339 + {...(isMobile ? { ...handlers } : {})} 340 + id={props.preview ? undefined : elementId.block(props.entityID).container} 341 + className="absolute group/canvas-block will-change-transform rounded-lg flex items-stretch touch-none origin-center p-3" 342 + style={{ 343 + top: 0, 344 + left: 0, 345 + zIndex: dragDelta ? 10 : undefined, 346 + width: width + (widthHandle.dragDelta?.x || 0), 347 + transform, 348 + }} 349 + > 350 + {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 351 + {!props.preview && <Gripper {...handlers} />} 352 + <BaseBlock 353 + {...blockProps} 354 + listData={ 355 + isList?.data.value 356 + ? { path: [], parent: props.parent, depth: 1 } 357 + : undefined 358 + } 359 + areYouSure={areYouSure} 360 + setAreYouSure={setAreYouSure} 361 + /> 362 + 363 + {!props.preview && ( 364 + <div 365 + className={`resizeHandle 366 + cursor-e-resize shrink-0 z-10 367 + hidden group-hover/canvas-block:block 368 + w-[5px] h-6 -ml-[3px] 369 + absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 370 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,_inset_0_0_0_1px_white]`} 371 + {...widthHandle.handlers} 372 + /> 373 + )} 374 + 375 + {!props.preview && ( 376 + <div 377 + className={`rotateHandle 378 + cursor-grab shrink-0 z-10 379 + hidden group-hover/canvas-block:block 380 + w-[8px] h-[8px] 381 + absolute bottom-0 -right-0 382 + -translate-y-1/2 -translate-x-1/2 383 + 384 + 385 + rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,_inset_0_0_0_1px_white]`} 386 + {...rotateHandle.handlers} 387 + /> 388 + )} 389 + </div> 390 + ); 391 + } 392 + 393 + export const CanvasBackground = () => { 394 + return ( 395 + <svg 396 + width="100%" 397 + height="100%" 398 + xmlns="http://www.w3.org/2000/svg" 399 + className="pointer-events-none text-border-light" 400 + > 401 + <defs> 402 + <pattern 403 + id="gridPattern" 404 + x="0" 405 + y="0" 406 + width="32" 407 + height="32" 408 + patternUnits="userSpaceOnUse" 409 + > 410 + <path 411 + fillRule="evenodd" 412 + clipRule="evenodd" 413 + 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" 414 + fill="currentColor" 415 + /> 416 + </pattern> 417 + </defs> 418 + <rect width="100%" height="100%" x="0" y="0" fill="url(#gridPattern)" /> 419 + </svg> 420 + ); 421 + }; 422 + 423 + const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 424 + return ( 425 + <div 426 + onMouseDown={props.onMouseDown} 427 + onPointerDown={props.onMouseDown} 428 + className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 429 + > 430 + <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 431 + <div 432 + className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 433 + style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "space" }} 434 + /> 435 + <div 436 + className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 437 + style={{ maskImage: "var(--gripperSVG)", maskRepeat: "space" }} 438 + /> 439 + </Media> 440 + </div> 441 + ); 442 + }; 443 + 444 + type P = { x: number; y: number }; 445 + function find_angle(P2: P, P1: P, P3: P) { 446 + if (P1.x === P3.x && P1.y === P3.y) return 0; 447 + let a = Math.atan2(P3.y - P1.y, P3.x - P1.x); 448 + let b = Math.atan2(P2.y - P1.y, P2.x - P1.x); 449 + return a - b; 450 + }
+9 -7
components/DesktopFooter.tsx
··· 3 import { Media } from "./Media"; 4 import { Toolbar } from "./Toolbar"; 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 7 export function DesktopPageFooter(props: { pageID: string }) { 8 - let focusedBlock = useUIState((s) => s.focusedEntity); 9 let focusedBlockParentID = 10 - focusedBlock?.entityType === "page" 11 - ? focusedBlock.entityID 12 - : focusedBlock?.parent; 13 let entity_set = useEntitySetContext(); 14 return ( 15 <Media 16 mobile={false} 17 className="absolute bottom-4 w-full z-10 pointer-events-none" 18 > 19 - {focusedBlock && 20 - focusedBlock.entityType === "block" && 21 entity_set.permissions.write && 22 focusedBlockParentID === props.pageID && ( 23 <div ··· 28 > 29 <Toolbar 30 pageID={focusedBlockParentID} 31 - blockID={focusedBlock.entityID} 32 /> 33 </div> 34 )}
··· 3 import { Media } from "./Media"; 4 import { Toolbar } from "./Toolbar"; 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 8 export function DesktopPageFooter(props: { pageID: string }) { 9 + let focusedEntity = useUIState((s) => s.focusedEntity); 10 let focusedBlockParentID = 11 + focusedEntity?.entityType === "page" 12 + ? focusedEntity.entityID 13 + : focusedEntity?.parent; 14 let entity_set = useEntitySetContext(); 15 + 16 return ( 17 <Media 18 mobile={false} 19 className="absolute bottom-4 w-full z-10 pointer-events-none" 20 > 21 + {focusedEntity && 22 + focusedEntity.entityType === "block" && 23 entity_set.permissions.write && 24 focusedBlockParentID === props.pageID && ( 25 <div ··· 30 > 31 <Toolbar 32 pageID={focusedBlockParentID} 33 + blockID={focusedEntity.entityID} 34 /> 35 </div> 36 )}
+82 -2
components/Icons.tsx
··· 2 3 type Props = SVGProps<SVGSVGElement>; 4 5 export const HomeMedium = (props: Props) => { 6 return ( 7 <svg ··· 84 ); 85 }; 86 87 - export const BlockPageLinkSmall = (props: Props) => { 88 return ( 89 <svg 90 width="24" ··· 104 ); 105 }; 106 107 export const BlockImageSmall = (props: Props) => { 108 return ( 109 <svg ··· 118 fillRule="evenodd" 119 clipRule="evenodd" 120 d="M19.2652 2.56494C20.8254 2.56494 22.0902 3.82974 22.0902 5.38994V18.6099C22.0902 20.1701 20.8254 21.4349 19.2652 21.4349H4.73516C3.17495 21.4349 1.91016 20.1701 1.91016 18.6099V5.38994C1.91016 3.82974 3.17495 2.56494 4.73516 2.56494H19.2652ZM20.8402 5.38994C20.8402 4.52009 20.135 3.81494 19.2652 3.81494L4.73516 3.81494C3.86531 3.81494 3.16016 4.52009 3.16016 5.38994L3.16016 18.6099C3.16016 19.4798 3.86531 20.1849 4.73516 20.1849H19.2652C20.135 20.1849 20.8402 19.4798 20.8402 18.6099V5.38994ZM18.585 4.68457C19.3444 4.68457 19.96 5.30018 19.96 6.05957V17.9406C19.96 18.7 19.3444 19.3156 18.585 19.3156H5.41549C4.65609 19.3156 4.04049 18.7 4.04049 17.9406L4.04049 6.05957C4.04049 5.30018 4.6561 4.68457 5.41549 4.68457L18.585 4.68457ZM18.71 6.05957C18.71 5.99053 18.654 5.93457 18.585 5.93457L5.41549 5.93457C5.34645 5.93457 5.29049 5.99054 5.29049 6.05957L5.29049 13.4913L7.14816 11.8333C7.4881 11.5298 7.99016 11.4946 8.36915 11.7475L11.4035 13.7726L15.948 9.05501C16.33 8.65847 16.9607 8.64532 17.3588 9.0256L18.71 10.316V6.05957ZM18.71 11.6988L16.6682 9.74878L13.8004 12.7258L13.9119 12.8594C14.0785 13.059 14.3703 13.0976 14.5831 12.9482L16.0566 11.9134C16.5024 11.6002 17.083 11.5535 17.5732 11.7912L18.71 12.3425V11.6988ZM18.71 13.4539L17.1369 12.691C16.9735 12.6117 16.7799 12.6273 16.6313 12.7317L15.1578 13.7665C14.5195 14.2148 13.644 14.099 13.1442 13.5002L13.1025 13.4503L12.2739 14.3105L14.7323 15.4676C14.9822 15.5852 15.0894 15.8831 14.9718 16.133C14.8542 16.3828 14.5563 16.49 14.3065 16.3724L11.5916 15.0946C11.5597 15.0796 11.5301 15.0616 11.503 15.0412L10.5445 14.4015L9.75598 15.1606C9.20763 15.6886 8.35079 15.7221 7.76287 15.2386L7.40631 14.9454C7.26576 14.8298 7.07351 14.7997 6.90436 14.8668L5.29049 15.507L5.29049 17.9406C5.29049 18.0096 5.34645 18.0656 5.41549 18.0656L18.585 18.0656C18.654 18.0656 18.71 18.0096 18.71 17.9406V13.4539ZM7.81405 12.5793L9.69292 13.8332L9.06241 14.4403C8.87963 14.6162 8.59401 14.6274 8.39804 14.4663L8.04147 14.173C7.61983 13.8263 7.04308 13.736 6.53564 13.9373L6.09815 14.1108L7.81405 12.5793ZM9.38337 8.58893C9.38337 8.2421 9.66453 7.96094 10.0114 7.96094C10.3582 7.96094 10.6393 8.2421 10.6393 8.58893C10.6393 8.93576 10.3582 9.21692 10.0114 9.21692C9.66453 9.21692 9.38337 8.93576 9.38337 8.58893ZM10.0114 6.96094C9.11225 6.96094 8.38337 7.68981 8.38337 8.58893C8.38337 9.48804 9.11225 10.2169 10.0114 10.2169C10.9105 10.2169 11.6393 9.48804 11.6393 8.58893C11.6393 7.68981 10.9105 6.96094 10.0114 6.96094Z" 121 fill="currentColor" 122 /> 123 </svg> ··· 919 ) => { 920 return ( 921 <svg 922 - {...props} 923 width="16" 924 height="8" 925 viewBox="0 0 16 8"
··· 2 3 type Props = SVGProps<SVGSVGElement>; 4 5 + export const AddBlockLarge = (props: Props) => { 6 + return ( 7 + <svg 8 + width="48" 9 + height="48" 10 + viewBox="0 0 48 48" 11 + fill="none" 12 + xmlns="http://www.w3.org/2000/svg" 13 + {...props} 14 + > 15 + <path 16 + fillRule="evenodd" 17 + clipRule="evenodd" 18 + d="M44.3387 18.4727C43.5485 22.7534 39.4377 25.583 35.157 24.7929C30.8763 24.0027 28.0467 19.892 28.8369 15.6112C29.627 11.3305 33.7378 8.50091 38.0185 9.29107C42.2992 10.0812 45.1288 14.192 44.3387 18.4727ZM38.4053 12.6882C38.5056 12.1451 38.1466 11.6236 37.6035 11.5233C37.0603 11.4231 36.5388 11.7821 36.4385 12.3252L35.783 15.8765L32.2355 15.2217C31.6924 15.1215 31.1709 15.4805 31.0706 16.0236C30.9704 16.5667 31.3294 17.0882 31.8725 17.1885L35.42 17.8433L34.7646 21.3937C34.6644 21.9368 35.0234 22.4583 35.5665 22.5586C36.1096 22.6588 36.6311 22.2998 36.7314 21.7567L37.3867 18.2063L40.941 18.8624C41.4841 18.9627 42.0056 18.6036 42.1059 18.0605C42.2061 17.5174 41.8471 16.9959 41.304 16.8956L37.7498 16.2396L38.4053 12.6882ZM32.3345 25.9102L31.2465 32.7897C31.1387 33.4715 30.4985 33.9369 29.8166 33.829L6.51021 30.1424C5.82833 30.0346 5.36298 29.3944 5.47082 28.7125L7.67454 14.7778C7.78237 14.0959 8.42249 13.6306 9.10433 13.7384L26.1558 16.4341C26.565 16.4987 26.9491 16.2195 27.0137 15.8104C27.0784 15.4012 26.7992 15.0171 26.39 14.9525L9.33851 12.2568C7.83845 12.0196 6.43018 13.0434 6.19296 14.5434L3.98924 28.4781C3.752 29.9783 4.77577 31.3867 6.2759 31.624L7.76407 31.8594C7.75599 33.4374 8.90012 34.8281 10.5065 35.0821L31.0528 38.3321C32.8255 38.6125 34.4898 37.4026 34.7702 35.6297L36.1526 26.8881C35.7175 26.8679 35.2785 26.818 34.8381 26.7367C33.9479 26.5724 33.109 26.2906 32.3345 25.9102ZM23.4131 19.2175C23.4241 19.6574 23.2883 20.1867 23.0307 20.79C22.8346 21.2494 22.4778 21.6318 21.978 21.8452C22.0168 21.9598 22.0619 22.0663 22.1132 22.1622C22.3032 22.5179 22.5369 22.6726 22.8273 22.6808C23.2833 22.6937 23.5648 22.5867 23.7725 22.4523C23.9937 22.3092 24.1598 22.1206 24.3827 21.8674L24.414 21.8319C24.6279 21.5892 24.9283 21.2485 25.3658 21.062C25.8384 20.8605 26.3702 20.8732 26.9904 21.0896C27.3164 21.2032 27.4884 21.5596 27.3747 21.8855C27.2611 22.2114 26.9047 22.3835 26.5788 22.2698C26.1594 22.1235 25.9629 22.1663 25.8559 22.2119C25.7138 22.2724 25.5832 22.3957 25.3518 22.6583L25.3005 22.7167C25.0984 22.9476 24.8244 23.2605 24.4516 23.5017C24.0225 23.7794 23.49 23.95 22.792 23.9303C21.9284 23.9059 21.346 23.3788 21.0107 22.7513C20.8879 22.5216 20.7932 22.2716 20.7232 22.0125C20.3247 21.9723 19.896 21.8557 19.4428 21.6622C19.1125 21.5212 18.9198 21.4918 18.8057 21.4891C18.6971 21.4865 18.6258 21.5046 18.479 21.5417L18.4537 21.5481C18.2835 21.5911 18.0321 21.6501 17.7068 21.5903C17.3997 21.5338 17.0843 21.3833 16.7196 21.1377C16.5266 21.0077 16.4435 21.0308 16.3974 21.0447C16.2903 21.0768 16.1681 21.1625 15.9391 21.349L15.899 21.3818C15.7149 21.5327 15.4281 21.768 15.0909 21.8768C14.6466 22.0204 14.1887 21.9362 13.7529 21.6044C13.2498 21.2214 13.2805 20.6053 13.3312 20.2533C13.3748 19.9507 13.4736 19.6002 13.5602 19.2927L13.5602 19.2927L13.5602 19.2926L13.5602 19.2926L13.5603 19.2924L13.5603 19.2923C13.5842 19.2077 13.607 19.1265 13.6276 19.0504C13.7362 18.6487 13.7944 18.3491 13.7781 18.1352C13.7708 18.0397 13.7503 17.9952 13.7383 17.9764C13.7319 17.9663 13.718 17.9466 13.6752 17.9235C13.6416 17.9532 13.5853 18.0127 13.5073 18.1252C13.345 18.359 13.191 18.6606 12.9979 19.0385C12.9017 19.2268 12.7958 19.434 12.6745 19.6612C11.9987 20.9262 10.9436 22.585 8.86603 23.0247C8.52833 23.0962 8.19663 22.8803 8.12516 22.5426C8.05369 22.2049 8.26952 21.8733 8.60721 21.8018C10.09 21.488 10.9117 20.3082 11.5719 19.0723C11.6592 18.9088 11.7469 18.7374 11.8347 18.5658L11.8347 18.5658C12.0515 18.142 12.2685 17.7178 12.4802 17.4127C12.6338 17.1913 12.8377 16.9484 13.1131 16.7993C13.4286 16.6284 13.7845 16.6093 14.1325 16.7578C14.412 16.8772 14.6373 17.0609 14.7929 17.3053C14.9443 17.5433 15.0063 17.8018 15.0244 18.0402C15.059 18.4941 14.9399 18.9858 14.8343 19.3765C14.8041 19.4883 14.7747 19.5929 14.7471 19.6915L14.747 19.6918L14.747 19.6918C14.6658 19.9808 14.5992 20.2183 14.5684 20.4316C14.5522 20.544 14.5526 20.6088 14.5555 20.6424C14.6575 20.7101 14.6918 20.6942 14.7038 20.6886L14.7068 20.6873C14.7984 20.6578 14.9076 20.5769 15.1497 20.3797L15.1677 20.3651C15.3626 20.2062 15.6654 19.9593 16.0381 19.8474C16.4835 19.7138 16.951 19.7866 17.4178 20.1008C17.7128 20.2995 17.8639 20.3482 17.9331 20.3609C17.9841 20.3703 18.0189 20.3687 18.1478 20.3361L18.1849 20.3266C18.3243 20.2906 18.5486 20.2327 18.835 20.2395C19.1491 20.2468 19.5006 20.3278 19.9336 20.5126C20.1681 20.6127 20.3768 20.6796 20.5613 20.7213L20.5614 20.6992C20.5663 20.0167 20.703 19.3053 20.9968 18.783C21.1443 18.5207 21.3598 18.2563 21.6689 18.1014C22.0034 17.9339 22.3758 17.9311 22.7301 18.0816C23.2199 18.2896 23.4018 18.7668 23.4131 19.2175ZM21.8246 20.4084C21.8595 19.9816 21.9598 19.6206 22.0863 19.3957C22.1153 19.3441 22.1416 19.3069 22.1636 19.2802C22.1608 19.4685 22.0952 19.7976 21.8811 20.2993C21.8648 20.3373 21.8461 20.3738 21.8246 20.4084ZM25.8841 26.6917C25.9373 26.3507 25.704 26.0311 25.3629 25.9779L11.7135 23.8482C11.3725 23.795 11.0529 24.0283 10.9997 24.3694C10.9465 24.7104 11.1798 25.0301 11.5209 25.0833L25.1703 27.2129C25.5113 27.2661 25.8309 27.0328 25.8841 26.6917ZM26.1751 29.0768C26.5163 29.1288 26.7508 29.4475 26.6989 29.7888C26.6469 30.13 26.3282 30.3645 25.9869 30.3125L10.7045 27.9849C10.3632 27.9329 10.1287 27.6142 10.1807 27.2729C10.2326 26.9317 10.5514 26.6972 10.8926 26.7492L26.1751 29.0768Z" 19 + fill="currentColor" 20 + /> 21 + </svg> 22 + ); 23 + }; 24 + 25 export const HomeMedium = (props: Props) => { 26 return ( 27 <svg ··· 104 ); 105 }; 106 107 + export const BlockDocPageSmall = (props: Props) => { 108 return ( 109 <svg 110 width="24" ··· 124 ); 125 }; 126 127 + export const BlockCanvasPageSmall = (props: Props) => { 128 + return ( 129 + <svg 130 + width="24" 131 + height="24" 132 + viewBox="0 0 24 24" 133 + fill="none" 134 + xmlns="http://www.w3.org/2000/svg" 135 + {...props} 136 + > 137 + <path 138 + fillRule="evenodd" 139 + clipRule="evenodd" 140 + d="M7.94752 4.8203C7.87263 4.55451 8.02738 4.27833 8.29318 4.20344L13.9579 2.60731C14.2237 2.53242 14.4999 2.68717 14.5748 2.95296L15.925 7.745C15.9999 8.01079 15.8451 8.28697 15.5794 8.36186L12.5721 9.20921C12.6984 9.55016 12.7675 9.91846 12.7675 10.3041C12.7675 12.1239 11.2305 13.5567 9.38824 13.5567C7.54598 13.5567 6.00903 12.1239 6.00903 10.3041C6.00903 8.75121 7.12826 7.48015 8.60134 7.14076L7.94752 4.8203ZM7.10903 10.3041C7.10903 9.13881 8.10545 8.15162 9.38824 8.15162C10.671 8.15162 11.6675 9.13881 11.6675 10.3041C11.6675 11.4695 10.671 12.4567 9.38824 12.4567C8.10545 12.4567 7.10903 11.4695 7.10903 10.3041ZM17.7845 7.09478C17.958 7.11191 18.11 7.21814 18.1858 7.37515L20.5088 12.1917C20.5856 12.3509 20.5726 12.5389 20.4747 12.686C20.3767 12.8331 20.2084 12.9176 20.0319 12.9082L14.3056 12.6034C14.121 12.5935 13.9569 12.4827 13.8789 12.3151C13.8008 12.1475 13.8217 11.9505 13.933 11.803L17.3362 7.29126C17.4412 7.15209 17.6111 7.07765 17.7845 7.09478ZM17.6411 8.54771L15.2974 11.6548L19.2409 11.8647L17.6411 8.54771ZM16.0462 4.44435C15.9972 4.10267 16.2345 3.78596 16.5762 3.73695L19.1579 3.3667C20.2536 3.20955 21.2725 3.96144 21.4455 5.05484L22.7584 13.3561C22.9331 14.4608 22.1793 15.4979 21.0746 15.6726L20.669 15.7368C20.4764 17.1163 19.4043 18.2674 17.9572 18.4963L5.89725 20.4037C4.12436 20.6841 2.45984 19.4742 2.17944 17.7013L1.25681 11.8679C1.026 10.4086 1.80495 9.02266 3.07649 8.42169L3.00465 7.9675C2.83207 6.87633 3.56591 5.84819 4.65394 5.65678L6.4948 5.33294C6.83476 5.27314 7.15883 5.50025 7.21863 5.84021C7.27844 6.18016 7.05133 6.50423 6.71137 6.56404L4.87051 6.88788C4.45411 6.96113 4.17325 7.35462 4.2393 7.77223L5.55287 16.0774C5.61974 16.5002 6.01666 16.7887 6.43943 16.7218L20.8794 14.438C21.3021 14.3711 21.5906 13.9742 21.5238 13.5514L20.2108 5.25012C20.1446 4.83166 19.7547 4.54389 19.3353 4.60404L16.7536 4.97429C16.4119 5.0233 16.0952 4.78603 16.0462 4.44435ZM6.6347 17.9565L19.0507 15.9927C18.8184 16.518 18.3324 16.9183 17.7229 17.0147L5.66292 18.9221C4.70829 19.0731 3.81201 18.4216 3.66102 17.467L2.73839 11.6336C2.63976 11.0099 2.88359 10.4112 3.33068 10.0289L4.31822 16.2727C4.49293 17.3773 5.53006 18.1312 6.6347 17.9565Z" 141 + fill="currentColor" 142 + /> 143 + </svg> 144 + ); 145 + }; 146 + 147 export const BlockImageSmall = (props: Props) => { 148 return ( 149 <svg ··· 158 fillRule="evenodd" 159 clipRule="evenodd" 160 d="M19.2652 2.56494C20.8254 2.56494 22.0902 3.82974 22.0902 5.38994V18.6099C22.0902 20.1701 20.8254 21.4349 19.2652 21.4349H4.73516C3.17495 21.4349 1.91016 20.1701 1.91016 18.6099V5.38994C1.91016 3.82974 3.17495 2.56494 4.73516 2.56494H19.2652ZM20.8402 5.38994C20.8402 4.52009 20.135 3.81494 19.2652 3.81494L4.73516 3.81494C3.86531 3.81494 3.16016 4.52009 3.16016 5.38994L3.16016 18.6099C3.16016 19.4798 3.86531 20.1849 4.73516 20.1849H19.2652C20.135 20.1849 20.8402 19.4798 20.8402 18.6099V5.38994ZM18.585 4.68457C19.3444 4.68457 19.96 5.30018 19.96 6.05957V17.9406C19.96 18.7 19.3444 19.3156 18.585 19.3156H5.41549C4.65609 19.3156 4.04049 18.7 4.04049 17.9406L4.04049 6.05957C4.04049 5.30018 4.6561 4.68457 5.41549 4.68457L18.585 4.68457ZM18.71 6.05957C18.71 5.99053 18.654 5.93457 18.585 5.93457L5.41549 5.93457C5.34645 5.93457 5.29049 5.99054 5.29049 6.05957L5.29049 13.4913L7.14816 11.8333C7.4881 11.5298 7.99016 11.4946 8.36915 11.7475L11.4035 13.7726L15.948 9.05501C16.33 8.65847 16.9607 8.64532 17.3588 9.0256L18.71 10.316V6.05957ZM18.71 11.6988L16.6682 9.74878L13.8004 12.7258L13.9119 12.8594C14.0785 13.059 14.3703 13.0976 14.5831 12.9482L16.0566 11.9134C16.5024 11.6002 17.083 11.5535 17.5732 11.7912L18.71 12.3425V11.6988ZM18.71 13.4539L17.1369 12.691C16.9735 12.6117 16.7799 12.6273 16.6313 12.7317L15.1578 13.7665C14.5195 14.2148 13.644 14.099 13.1442 13.5002L13.1025 13.4503L12.2739 14.3105L14.7323 15.4676C14.9822 15.5852 15.0894 15.8831 14.9718 16.133C14.8542 16.3828 14.5563 16.49 14.3065 16.3724L11.5916 15.0946C11.5597 15.0796 11.5301 15.0616 11.503 15.0412L10.5445 14.4015L9.75598 15.1606C9.20763 15.6886 8.35079 15.7221 7.76287 15.2386L7.40631 14.9454C7.26576 14.8298 7.07351 14.7997 6.90436 14.8668L5.29049 15.507L5.29049 17.9406C5.29049 18.0096 5.34645 18.0656 5.41549 18.0656L18.585 18.0656C18.654 18.0656 18.71 18.0096 18.71 17.9406V13.4539ZM7.81405 12.5793L9.69292 13.8332L9.06241 14.4403C8.87963 14.6162 8.59401 14.6274 8.39804 14.4663L8.04147 14.173C7.61983 13.8263 7.04308 13.736 6.53564 13.9373L6.09815 14.1108L7.81405 12.5793ZM9.38337 8.58893C9.38337 8.2421 9.66453 7.96094 10.0114 7.96094C10.3582 7.96094 10.6393 8.2421 10.6393 8.58893C10.6393 8.93576 10.3582 9.21692 10.0114 9.21692C9.66453 9.21692 9.38337 8.93576 9.38337 8.58893ZM10.0114 6.96094C9.11225 6.96094 8.38337 7.68981 8.38337 8.58893C8.38337 9.48804 9.11225 10.2169 10.0114 10.2169C10.9105 10.2169 11.6393 9.48804 11.6393 8.58893C11.6393 7.68981 10.9105 6.96094 10.0114 6.96094Z" 161 + fill="currentColor" 162 + /> 163 + </svg> 164 + ); 165 + }; 166 + 167 + export const CanvasWidenSmall = (props: Props) => { 168 + return ( 169 + <svg 170 + width="24" 171 + height="24" 172 + viewBox="0 0 24 24" 173 + fill="none" 174 + xmlns="http://www.w3.org/2000/svg" 175 + {...props} 176 + > 177 + <path 178 + fillRule="evenodd" 179 + clipRule="evenodd" 180 + d="M15.1743 4.01966C15.3499 4.16584 15.4829 4.36274 15.5492 4.59105C15.9689 6.0365 16.2813 7.25587 16.4324 8.71691C16.4888 9.26287 16.9235 9.70158 17.4717 9.72857C18.1226 9.76061 18.6527 9.20666 18.5472 8.56355C18.3311 7.24607 17.9903 6.05262 17.5745 4.64685C17.3995 4.05518 17.0191 3.54379 16.5026 3.20562L15.998 2.87523C15.5802 2.55315 15.0648 2.358 14.5147 2.33585L9.66029 2.0852C8.21539 2.02701 6.9813 3.13716 6.57544 4.38018C6.53473 4.50486 6.49186 4.63453 6.44736 4.76909L6.44727 4.76938L6.44727 4.76938L6.44727 4.76939C6.14496 5.68369 5.76803 6.82367 5.48829 8.15841C5.40839 8.53966 5.65287 8.91505 6.03436 8.99688C6.41585 9.07871 6.78988 8.83598 6.86978 8.45474C7.13208 7.20322 7.48311 6.14063 7.7857 5.22469L7.7857 5.22468C7.83082 5.08812 7.87485 4.95481 7.91736 4.82463C8.17214 4.04432 8.92595 3.46855 9.61051 3.49611L14.4649 3.74676C14.6455 3.75403 14.8171 3.80204 14.9697 3.8826C14.9855 3.89499 15.002 3.90681 15.0191 3.91802L15.1743 4.01966ZM16.1068 15.0104C16.2458 14.3937 16.7946 13.9424 17.426 13.9735C18.1288 14.0081 18.6655 14.6222 18.5525 15.3167C18.2704 17.0506 17.7884 18.4316 17.362 19.6533C17.3111 19.7992 17.261 19.9429 17.212 20.0846C16.8278 21.1979 15.7541 21.9829 14.535 21.9126L8.75878 21.5526C7.83348 21.4992 6.97249 20.9461 6.61206 20.0355C6.47743 19.6953 6.3217 19.2777 6.15685 18.789L6.15496 18.7833C6.04753 18.5559 5.96719 18.3169 5.91747 18.073C5.87457 17.8627 5.82702 17.6403 5.77708 17.4067L5.77707 17.4067L5.77707 17.4066L5.77706 17.4066C5.57296 16.4521 5.32877 15.31 5.19742 14.0316C5.15755 13.6435 5.43953 13.2981 5.82725 13.2601C6.21497 13.2222 6.56161 13.506 6.60148 13.8941C6.7242 15.0884 6.9482 16.1375 7.151 17.0873L7.151 17.0873C7.20287 17.3303 7.25336 17.5667 7.3004 17.7974C7.44785 18.5204 8.04125 19.0656 8.66574 19.1016L13.7521 19.4218C14.3012 19.4535 14.8276 19.0953 15.0204 18.5368C15.069 18.3959 15.1186 18.2535 15.1683 18.111C15.4997 17.1601 15.8466 16.1648 16.1068 15.0104ZM16.8253 11.0502C16.4805 11.0336 16.1876 11.2996 16.171 11.6444C16.1544 11.9892 16.4204 12.2821 16.7652 12.2987L21.4252 12.5231L19.3281 14.4972C19.0767 14.7338 19.0648 15.1294 19.3014 15.3807C19.5379 15.632 19.9335 15.644 20.1848 15.4074L23.369 12.4101C23.6204 12.1735 23.6323 11.7779 23.3958 11.5266L20.3984 8.34231C20.1618 8.09097 19.7663 8.07901 19.5149 8.3156C19.2636 8.55219 19.2516 8.94774 19.4882 9.19908L21.4398 11.2724L16.8253 11.0502ZM6.44929 11.1706C6.43612 11.5155 6.14582 11.7844 5.8009 11.7713L2.45713 11.6436L4.15706 13.6811C4.3768 13.9445 4.33905 14.3387 4.07273 14.5617C3.80641 14.7846 3.41238 14.7519 3.19263 14.4885L0.57729 11.3538C0.378121 11.1151 0.388107 10.7637 0.600661 10.5315L3.39174 7.48236C3.62625 7.22616 4.02179 7.20587 4.2752 7.43705C4.52861 7.66823 4.54393 8.06333 4.30942 8.31953L2.41326 10.391L5.84859 10.5222C6.19352 10.5354 6.46246 10.8257 6.44929 11.1706ZM10.2577 10.2188C9.57038 10.3628 9.4682 10.8083 9.43281 11.2726C9.39743 11.7368 9.50304 12.1792 9.94506 12.4174C10.2332 12.5726 10.4738 12.4974 10.7261 12.4185C10.899 12.3644 11.0775 12.3086 11.2806 12.3241C11.4757 12.3389 11.6374 12.4219 11.798 12.5044C12.0486 12.633 12.2963 12.7601 12.6635 12.6246C13.1345 12.4506 13.3496 12.0381 13.385 11.5738C13.4204 11.1096 13.3786 10.7118 12.8319 10.415C12.6107 10.2948 12.3314 10.3429 12.0364 10.3938C11.835 10.4285 11.6262 10.4645 11.4236 10.4491C11.2047 10.4324 11.0334 10.3694 10.8752 10.3113C10.6723 10.2367 10.4908 10.1699 10.2577 10.2188Z" 181 + fill="currentColor" 182 + /> 183 + </svg> 184 + ); 185 + }; 186 + 187 + export const CanvasShrinkSmall = (props: Props) => { 188 + return ( 189 + <svg 190 + width="24" 191 + height="24" 192 + viewBox="0 0 24 24" 193 + fill="none" 194 + xmlns="http://www.w3.org/2000/svg" 195 + {...props} 196 + > 197 + <path 198 + fillRule="evenodd" 199 + clipRule="evenodd" 200 + d="M5.32382 1.81409C3.62426 1.7384 2.32074 3.2618 2.55669 4.90847C2.69254 5.8566 2.83388 6.99183 2.926 8.13215C2.95787 8.52671 3.30356 8.82072 3.69812 8.78885C4.09267 8.75697 4.38668 8.41128 4.35481 8.01673C4.25959 6.83801 4.11419 5.6719 3.97566 4.70515C3.86176 3.91023 4.49553 3.21209 5.26005 3.24614L18.762 3.84745C19.554 3.88273 20.1367 4.68979 19.9058 5.49876C19.6543 6.37951 19.3778 7.43121 19.1502 8.50902L19.1357 8.57793C18.9978 9.23778 19.5058 9.84966 20.1793 9.87965C20.72 9.90373 21.2053 9.53162 21.329 9.00461C21.5347 8.12791 21.7652 7.28195 21.9774 6.55529C22.2152 5.74143 21.9429 5.01795 21.597 4.45769C21.3218 4.01194 20.9439 3.58429 20.633 3.23253C20.5658 3.15655 20.5018 3.08411 20.4426 3.01579L20.4172 3.03779C19.9904 2.6758 19.445 2.44299 18.8258 2.41541L5.32382 1.81409ZM18.8637 15.2521C18.793 14.6728 19.2662 14.1798 19.8493 14.2058C20.3469 14.228 20.7447 14.6248 20.7895 15.1208C20.9076 16.4301 21.1308 17.8003 21.3492 18.9413C21.6722 20.629 20.3566 22.2645 18.5915 22.1859L4.90726 21.5765C4.50916 21.5587 4.16181 21.3799 3.89467 21.1902C3.61998 20.9952 3.36896 20.745 3.15235 20.4924C2.85913 20.1504 2.5978 19.7673 2.39703 19.425C1.99104 18.8188 1.82469 18.0483 2.00554 17.2806C2.23841 16.292 2.49606 15.0946 2.69434 13.8988C2.75908 13.5083 3.12814 13.2442 3.51865 13.309C3.90916 13.3737 4.17325 13.7428 4.1085 14.1333C3.90328 15.371 3.63818 16.6016 3.40081 17.6093C3.33953 17.8694 3.36242 18.1259 3.44756 18.3541C3.49084 18.4441 3.54557 18.5476 3.60958 18.6587C3.82397 18.9561 4.16198 19.1585 4.55061 19.1758L18.0526 19.7771C18.8446 19.8124 19.4967 19.0603 19.3386 18.234C19.1708 17.3573 18.9938 16.3168 18.8637 15.2521ZM20.7508 12.653C21.0956 12.669 21.3881 12.4025 21.4041 12.0576C21.4201 11.7128 21.1535 11.4203 20.8087 11.4043L15.6449 11.1647L17.8114 9.19037C18.0665 8.95787 18.0849 8.56257 17.8524 8.30744C17.6199 8.05231 17.2246 8.03396 16.9694 8.26647L13.6875 11.2573C13.4324 11.4898 13.4141 11.8851 13.6466 12.1402L16.6374 15.4222C16.8699 15.6773 17.2652 15.6956 17.5203 15.4631C17.7754 15.2306 17.7938 14.8353 17.5613 14.5802L15.5867 12.4134L20.7508 12.653ZM2.5893 10.4984C2.2445 10.4824 1.95201 10.749 1.93602 11.0938C1.92003 11.4386 2.18659 11.7311 2.5314 11.7471L7.02217 11.9553L5.15232 14.0418C4.92195 14.2989 4.94359 14.694 5.20064 14.9244C5.4577 15.1547 5.85284 15.1331 6.08321 14.876L8.83163 11.8092C9.02973 11.5881 9.04503 11.2583 8.86825 11.0198L6.41546 7.71171C6.20988 7.43444 5.81844 7.37632 5.54116 7.5819C5.26389 7.78749 5.20577 8.17893 5.41135 8.4562L7.07994 10.7067L2.5893 10.4984ZM9.95914 12.7437C10.1403 13.4334 10.5974 13.5142 11.07 13.5264C11.5425 13.5386 11.9857 13.4089 12.2047 12.9485C12.3474 12.6483 12.2589 12.4082 12.1659 12.1564C12.1023 11.9838 12.0365 11.8056 12.0419 11.5989C12.047 11.4003 12.1229 11.232 12.1983 11.065C12.3159 10.8043 12.4322 10.5466 12.276 10.1811C12.0755 9.71236 11.6462 9.51533 11.1736 9.50316C10.701 9.49099 10.2998 9.55369 10.0266 10.1232C9.91607 10.3538 9.97916 10.6345 10.0458 10.931C10.0913 11.1335 10.1385 11.3434 10.1332 11.5497C10.1274 11.7725 10.0723 11.9494 10.0214 12.1128C9.95613 12.3224 9.89773 12.5099 9.95914 12.7437Z" 201 fill="currentColor" 202 /> 203 </svg> ··· 999 ) => { 1000 return ( 1001 <svg 1002 + {...{ props, arrowFill: undefined, arrowStroke: undefined }} 1003 width="16" 1004 height="8" 1005 viewBox="0 0 16 8"
+1 -1
components/MobileFooter.tsx
··· 13 let entity_set = useEntitySetContext(); 14 15 return ( 16 - <Media mobile className="mobileFooter w-full z-10 -mt-6 touch-none"> 17 {focusedBlock && 18 focusedBlock.entityType == "block" && 19 entity_set.permissions.write ? (
··· 13 let entity_set = useEntitySetContext(); 14 15 return ( 16 + <Media mobile className="mobileFooter w-full z-10 touch-none"> 17 {focusedBlock && 18 focusedBlock.entityType == "block" && 19 entity_set.permissions.write ? (
+119 -28
components/Pages.tsx
··· 1 "use client"; 2 import { useUIState } from "src/useUIState"; 3 - import { Blocks } from "components/Blocks"; 4 import { focusBlock } from "src/utils/focusBlock"; 5 - import useMeasure from "react-use-measure"; 6 import { elementId } from "src/utils/elementId"; 7 - import { ThemePopover } from "./ThemeManager/ThemeSetter"; 8 - import { Media } from "./Media"; 9 - import { DesktopPageFooter } from "./DesktopFooter"; 10 import { Replicache } from "replicache"; 11 import { 12 Fact, 13 ReplicacheMutators, 14 useReferenceToEntity, 15 useReplicache, 16 } from "src/replicache"; 17 - import * as Popover from "@radix-ui/react-popover"; 18 - import { MoreOptionsTiny, DeleteSmall, CloseTiny, PopoverArrow } from "./Icons"; 19 - import { useToaster } from "./Toast"; 20 import { ShareOptions } from "./ShareOptions"; 21 - import { MenuItem, Menu } from "./Layout"; 22 - import { useEntitySetContext } from "./EntitySetProvider"; 23 import { HomeButton } from "./HomeButton"; 24 - import { useSearchParams } from "next/navigation"; 25 - import { useEffect } from "react"; 26 import { DraftPostOptions } from "./Blocks/MailboxBlock"; 27 import { useIsMobile } from "src/hooks/isMobile"; 28 import { HelpPopover } from "./HelpPopover"; 29 ··· 42 return ( 43 <div 44 id="pages" 45 - className="pages flex pt-2 pb-8 sm:py-6" 46 onClick={(e) => { 47 e.currentTarget === e.target && blurPage(); 48 }} ··· 106 : focusedElement?.parent; 107 let isFocused = focusedPageID === props.entityID; 108 let isMobile = useIsMobile(); 109 110 return ( 111 <> ··· 118 /> 119 )} 120 <div className="pageWrapper w-fit flex relative snap-center"> 121 <div 122 onMouseDown={(e) => { 123 if (e.defaultPrevented) return; 124 - if (!isMobile) return; 125 if (rep) { 126 focusPage(props.entityID, rep); 127 } ··· 129 id={elementId.page(props.entityID).container} 130 style={{ 131 backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 132 - width: "var(--page-width-units)", 133 }} 134 className={` 135 page 136 grow flex flex-col 137 overscroll-y-none ··· 141 `} 142 > 143 <Media mobile={true}> 144 - {!props.first && <PageOptionsMenu entityID={props.entityID} />} 145 </Media> 146 <DesktopPageFooter pageID={props.entityID} /> 147 {isDraft.length > 0 && ( ··· 155 <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 156 </div> 157 )} 158 - <Blocks entityID={props.entityID} /> 159 </div> 160 <Media mobile={false}> 161 - {isFocused && !props.first && ( 162 - <PageOptionsMenu entityID={props.entityID} /> 163 )} 164 </Media> 165 </div> ··· 167 ); 168 } 169 170 - const PageOptionsMenu = (props: { entityID: string }) => { 171 let permission = useEntitySetContext().permissions.write; 172 return ( 173 <div className=" z-10 w-fit absolute sm:top-2 sm:-right-[18px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 174 - <button 175 - className="p-1 pt-[10px] sm:p-0.5 sm:pl-0 bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2" 176 - onClick={() => { 177 - useUIState.getState().closePage(props.entityID); 178 - }} 179 > 180 - <CloseTiny /> 181 - </button> 182 - {/* {permission && <OptionsMenu/>} */} 183 </div> 184 ); 185 };
··· 1 "use client"; 2 + import { useEffect } from "react"; 3 import { useUIState } from "src/useUIState"; 4 + import { useEntitySetContext } from "./EntitySetProvider"; 5 + import { useSearchParams } from "next/navigation"; 6 + import { useToaster } from "./Toast"; 7 + 8 import { focusBlock } from "src/utils/focusBlock"; 9 import { elementId } from "src/utils/elementId"; 10 + import { theme } from "tailwind.config"; 11 + 12 import { Replicache } from "replicache"; 13 import { 14 Fact, 15 ReplicacheMutators, 16 + useEntity, 17 useReferenceToEntity, 18 useReplicache, 19 } from "src/replicache"; 20 + 21 + import { Media } from "./Media"; 22 + import { DesktopPageFooter } from "./DesktopFooter"; 23 import { ShareOptions } from "./ShareOptions"; 24 + import { ThemePopover } from "./ThemeManager/ThemeSetter"; 25 import { HomeButton } from "./HomeButton"; 26 + import { Canvas } from "./Canvas"; 27 import { DraftPostOptions } from "./Blocks/MailboxBlock"; 28 + import { Blocks } from "components/Blocks"; 29 + import { MenuItem, Menu } from "./Layout"; 30 + import { 31 + MoreOptionsTiny, 32 + DeleteSmall, 33 + CloseTiny, 34 + PopoverArrow, 35 + BlockDocPageSmall, 36 + BlockCanvasPageSmall, 37 + } from "./Icons"; 38 + import { useEditorStates } from "src/state/useEditorState"; 39 import { useIsMobile } from "src/hooks/isMobile"; 40 import { HelpPopover } from "./HelpPopover"; 41 ··· 54 return ( 55 <div 56 id="pages" 57 + className="pages flex pt-2 pb-1 sm:pb-8 sm:py-6" 58 onClick={(e) => { 59 e.currentTarget === e.target && blurPage(); 60 }} ··· 118 : focusedElement?.parent; 119 let isFocused = focusedPageID === props.entityID; 120 let isMobile = useIsMobile(); 121 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 122 123 return ( 124 <> ··· 131 /> 132 )} 133 <div className="pageWrapper w-fit flex relative snap-center"> 134 + {props.first && ( 135 + <SwitchPageTypeButton entityID={props.entityID} pageType={type} /> 136 + )} 137 + 138 <div 139 onMouseDown={(e) => { 140 if (e.defaultPrevented) return; 141 if (rep) { 142 focusPage(props.entityID, rep); 143 } ··· 145 id={elementId.page(props.entityID).container} 146 style={{ 147 backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 148 + width: type === "doc" ? "var(--page-width-units)" : undefined, 149 }} 150 className={` 151 + ${type === "canvas" ? "!lg:max-w-[1152px]" : "max-w-[var(--page-width-units)]"} 152 page 153 grow flex flex-col 154 overscroll-y-none ··· 158 `} 159 > 160 <Media mobile={true}> 161 + <PageOptionsMenu entityID={props.entityID} first={props.first} /> 162 </Media> 163 <DesktopPageFooter pageID={props.entityID} /> 164 {isDraft.length > 0 && ( ··· 172 <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 173 </div> 174 )} 175 + 176 + <PageContent entityID={props.entityID} /> 177 </div> 178 <Media mobile={false}> 179 + {isFocused && ( 180 + <PageOptionsMenu entityID={props.entityID} first={props.first} /> 181 )} 182 </Media> 183 </div> ··· 185 ); 186 } 187 188 + const PageContent = (props: { entityID: string }) => { 189 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 190 + if (type === "doc") return <Blocks entityID={props.entityID} />; 191 + return <Canvas entityID={props.entityID} />; 192 + }; 193 + 194 + const PageOptionsMenu = (props: { 195 + entityID: string; 196 + first: boolean | undefined; 197 + }) => { 198 let permission = useEntitySetContext().permissions.write; 199 + if (!permission) return; 200 return ( 201 <div className=" z-10 w-fit absolute sm:top-2 sm:-right-[18px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start"> 202 + {!props.first && ( 203 + <button 204 + className="p-1 pt-[10px] sm:p-0.5 sm:pl-0 bg-border text-bg-page sm:rounded-r-md sm:rounded-l-none rounded-b-md hover:bg-accent-1 hover:text-accent-2" 205 + onClick={() => { 206 + useUIState.getState().closePage(props.entityID); 207 + }} 208 + > 209 + <CloseTiny /> 210 + </button> 211 + )} 212 + </div> 213 + ); 214 + }; 215 + 216 + const SwitchPageTypeButton = (props: { 217 + entityID: string; 218 + pageType: "doc" | "canvas"; 219 + }) => { 220 + let { rep } = useReplicache(); 221 + let blocks = useEntity( 222 + props.entityID, 223 + props.pageType === "doc" ? "card/block" : "canvas/block", 224 + ); 225 + let firstBlockText = useEditorStates((s) => 226 + blocks[0]?.data.value ? s.editorStates[blocks[0].data.value] : null, 227 + ); 228 + let permission = useEntitySetContext().permissions.write; 229 + if (!permission) return; 230 + if (blocks.length > 1) return null; 231 + if ( 232 + firstBlockText?.editor && 233 + firstBlockText?.editor.doc.textContent.length > 0 234 + ) 235 + return null; 236 + return ( 237 + <div className="flex gap-0 absolute top-2 right-2 sm:top-0 sm:-right-10 z-20"> 238 + <Media mobile={false}> 239 + <div className="h-fit mt-[30px] -mr-[5px] rotate-90"> 240 + <PopoverArrow 241 + arrowFill={theme.colors["bg-page"]} 242 + arrowStroke={theme.colors.border} 243 + /> 244 + </div> 245 + </Media> 246 + <div 247 + className={`flex sm:flex-col gap-1 rounded-full border bg-bg-page border-border p-0.5`} 248 > 249 + <button 250 + className={`rounded-full p-0.5 border-2 ${props.pageType === "doc" ? "bg-tertiary text-bg-page border-tertiary" : "border-transparent text-border"}`} 251 + onClick={() => { 252 + rep?.mutate.assertFact({ 253 + entity: props.entityID, 254 + attribute: "page/type", 255 + data: { type: "page-type-union", value: "doc" }, 256 + }); 257 + }} 258 + > 259 + <BlockDocPageSmall /> 260 + </button> 261 + <button 262 + className={`rounded-full p-0.5 border-2 ${props.pageType === "canvas" ? "bg-tertiary text-bg-page border-tertiary" : "border-transparent text-border"}`} 263 + onClick={() => { 264 + rep?.mutate.assertFact({ 265 + entity: props.entityID, 266 + attribute: "page/type", 267 + data: { type: "page-type-union", value: "canvas" }, 268 + }); 269 + }} 270 + > 271 + <BlockCanvasPageSmall /> 272 + </button> 273 + </div> 274 </div> 275 ); 276 };
+129 -115
components/Toolbar/BlockToolbar.tsx
··· 1 import { DeleteSmall, MoveBlockDown, MoveBlockUp } from "components/Icons"; 2 - import { useReplicache } from "src/replicache"; 3 import { ToolbarButton } from "."; 4 import { Separator, ShortcutKey } from "components/Layout"; 5 import { metaKey } from "src/utils/metaKey"; ··· 9 export const BlockToolbar = (props: { 10 setToolbarState: (state: "areYouSure" | "block") => void; 11 }) => { 12 let { rep } = useReplicache(); 13 - 14 const getSortedSelection = async () => { 15 let selectedBlocks = useUIState.getState().selectedBlocks; 16 let siblings = ··· 22 ); 23 return [sortedBlocks, siblings]; 24 }; 25 - 26 return ( 27 - <div className="flex items-center gap-2 justify-between w-full"> 28 - <div className="flex items-center gap-2"> 29 - <ToolbarButton 30 - onClick={() => { 31 - props.setToolbarState("areYouSure"); 32 - }} 33 - tooltipContent="Delete Block" 34 - > 35 - <DeleteSmall /> 36 - </ToolbarButton> 37 - 38 - <Separator classname="h-5" /> 39 - <ToolbarButton 40 - onClick={async () => { 41 - let [sortedBlocks, siblings] = await getSortedSelection(); 42 - if (sortedBlocks.length > 1) return; 43 - let block = sortedBlocks[0]; 44 - let previousBlock = 45 siblings?.[ 46 - siblings.findIndex((s) => s.value === block.value) - 1 47 ]; 48 - if (previousBlock.value === block.listData?.parent) { 49 - previousBlock = 50 - siblings?.[ 51 - siblings.findIndex((s) => s.value === block.value) - 2 52 - ]; 53 - } 54 - 55 - if ( 56 - previousBlock?.listData && 57 - block.listData && 58 - block.listData.depth > 1 && 59 - !previousBlock.listData.path.find( 60 - (f) => f.entity === block.listData?.parent, 61 - ) 62 - ) { 63 - let depth = block.listData.depth; 64 - let newParent = previousBlock.listData.path.find( 65 - (f) => f.depth === depth - 1, 66 - ); 67 - if (!newParent) return; 68 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 69 - useUIState.getState().toggleFold(newParent.entity); 70 - rep?.mutate.moveBlock({ 71 - block: block.value, 72 - oldParent: block.listData?.parent, 73 - newParent: newParent.entity, 74 - position: { type: "end" }, 75 - }); 76 - } else { 77 - rep?.mutate.moveBlockUp({ 78 - entityID: block.value, 79 - parent: block.listData?.parent || block.parent, 80 - }); 81 - } 82 - }} 83 - tooltipContent={ 84 - <div className="flex flex-col gap-1 justify-center"> 85 - <div className="text-center">Move Up</div> 86 - <div className="flex gap-1"> 87 - <ShortcutKey>Shift</ShortcutKey> +{" "} 88 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 89 - <ShortcutKey> ↑ </ShortcutKey> 90 - </div> 91 - </div> 92 } 93 - > 94 - <MoveBlockUp /> 95 - </ToolbarButton> 96 97 - <ToolbarButton 98 - onClick={async () => { 99 - let [sortedBlocks, siblings] = await getSortedSelection(); 100 - if (sortedBlocks.length > 1) return; 101 - let block = sortedBlocks[0]; 102 - let nextBlock = siblings 103 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 104 - .find( 105 - (f) => 106 - f.listData && 107 - block.listData && 108 - !f.listData.path.find((f) => f.entity === block.value), 109 - ); 110 - if ( 111 - nextBlock?.listData && 112 - block.listData && 113 - nextBlock.listData.depth === block.listData.depth - 1 114 - ) { 115 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 116 - useUIState.getState().toggleFold(nextBlock.value); 117 - rep?.mutate.moveBlock({ 118 - block: block.value, 119 - oldParent: block.listData?.parent, 120 - newParent: nextBlock.value, 121 - position: { type: "first" }, 122 - }); 123 - } else { 124 - rep?.mutate.moveBlockDown({ 125 - entityID: block.value, 126 - parent: block.listData?.parent || block.parent, 127 - }); 128 - } 129 - }} 130 - tooltipContent={ 131 - <div className="flex flex-col gap-1 justify-center"> 132 - <div className="text-center">Move Down</div> 133 - <div className="flex gap-1"> 134 - <ShortcutKey>Shift</ShortcutKey> +{" "} 135 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 136 - <ShortcutKey> ↓ </ShortcutKey> 137 - </div> 138 </div> 139 } 140 - > 141 - <MoveBlockDown /> 142 - </ToolbarButton> 143 - </div> 144 - </div> 145 ); 146 };
··· 1 import { DeleteSmall, MoveBlockDown, MoveBlockUp } from "components/Icons"; 2 + import { useEntity, useReplicache } from "src/replicache"; 3 import { ToolbarButton } from "."; 4 import { Separator, ShortcutKey } from "components/Layout"; 5 import { metaKey } from "src/utils/metaKey"; ··· 9 export const BlockToolbar = (props: { 10 setToolbarState: (state: "areYouSure" | "block") => void; 11 }) => { 12 + let focusedEntity = useUIState((s) => s.focusedEntity); 13 + let focusedEntityType = useEntity( 14 + focusedEntity?.entityType === "page" 15 + ? focusedEntity.entityID 16 + : focusedEntity?.parent || null, 17 + "page/type", 18 + ); 19 + 20 + return ( 21 + <div className="flex items-center gap-2 justify-between w-full"> 22 + <div className="flex items-center gap-2"> 23 + <ToolbarButton 24 + onClick={() => { 25 + props.setToolbarState("areYouSure"); 26 + }} 27 + tooltipContent="Delete Block" 28 + > 29 + <DeleteSmall /> 30 + </ToolbarButton> 31 + 32 + {focusedEntityType?.data.value !== "canvas" ? ( 33 + <MoveBlockButtons /> 34 + ) : null} 35 + </div> 36 + </div> 37 + ); 38 + }; 39 + 40 + const MoveBlockButtons = () => { 41 let { rep } = useReplicache(); 42 const getSortedSelection = async () => { 43 let selectedBlocks = useUIState.getState().selectedBlocks; 44 let siblings = ··· 50 ); 51 return [sortedBlocks, siblings]; 52 }; 53 return ( 54 + <> 55 + <Separator classname="h-5" /> 56 + <ToolbarButton 57 + onClick={async () => { 58 + let [sortedBlocks, siblings] = await getSortedSelection(); 59 + if (sortedBlocks.length > 1) return; 60 + let block = sortedBlocks[0]; 61 + let previousBlock = 62 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 63 + if (previousBlock.value === block.listData?.parent) { 64 + previousBlock = 65 siblings?.[ 66 + siblings.findIndex((s) => s.value === block.value) - 2 67 ]; 68 } 69 70 + if ( 71 + previousBlock?.listData && 72 + block.listData && 73 + block.listData.depth > 1 && 74 + !previousBlock.listData.path.find( 75 + (f) => f.entity === block.listData?.parent, 76 + ) 77 + ) { 78 + let depth = block.listData.depth; 79 + let newParent = previousBlock.listData.path.find( 80 + (f) => f.depth === depth - 1, 81 + ); 82 + if (!newParent) return; 83 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 84 + useUIState.getState().toggleFold(newParent.entity); 85 + rep?.mutate.moveBlock({ 86 + block: block.value, 87 + oldParent: block.listData?.parent, 88 + newParent: newParent.entity, 89 + position: { type: "end" }, 90 + }); 91 + } else { 92 + rep?.mutate.moveBlockUp({ 93 + entityID: block.value, 94 + parent: block.listData?.parent || block.parent, 95 + }); 96 + } 97 + }} 98 + tooltipContent={ 99 + <div className="flex flex-col gap-1 justify-center"> 100 + <div className="text-center">Move Up</div> 101 + <div className="flex gap-1"> 102 + <ShortcutKey>Shift</ShortcutKey> +{" "} 103 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 104 + <ShortcutKey> ↑ </ShortcutKey> 105 </div> 106 + </div> 107 + } 108 + > 109 + <MoveBlockUp /> 110 + </ToolbarButton> 111 + 112 + <ToolbarButton 113 + onClick={async () => { 114 + let [sortedBlocks, siblings] = await getSortedSelection(); 115 + if (sortedBlocks.length > 1) return; 116 + let block = sortedBlocks[0]; 117 + let nextBlock = siblings 118 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 119 + .find( 120 + (f) => 121 + f.listData && 122 + block.listData && 123 + !f.listData.path.find((f) => f.entity === block.value), 124 + ); 125 + if ( 126 + nextBlock?.listData && 127 + block.listData && 128 + nextBlock.listData.depth === block.listData.depth - 1 129 + ) { 130 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 131 + useUIState.getState().toggleFold(nextBlock.value); 132 + rep?.mutate.moveBlock({ 133 + block: block.value, 134 + oldParent: block.listData?.parent, 135 + newParent: nextBlock.value, 136 + position: { type: "first" }, 137 + }); 138 + } else { 139 + rep?.mutate.moveBlockDown({ 140 + entityID: block.value, 141 + parent: block.listData?.parent || block.parent, 142 + }); 143 } 144 + }} 145 + tooltipContent={ 146 + <div className="flex flex-col gap-1 justify-center"> 147 + <div className="text-center">Move Down</div> 148 + <div className="flex gap-1"> 149 + <ShortcutKey>Shift</ShortcutKey> +{" "} 150 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 151 + <ShortcutKey> ↓ </ShortcutKey> 152 + </div> 153 + </div> 154 + } 155 + > 156 + <MoveBlockDown /> 157 + </ToolbarButton> 158 + </> 159 ); 160 };
+7 -8
components/Toolbar/index.tsx
··· 34 35 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 36 37 - let focusedBlock = useUIState((s) => s.focusedEntity); 38 let selectedBlocks = useUIState((s) => s.selectedBlocks); 39 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 40 ··· 146 <button 147 className="toolbarBackToDefault hover:text-accent-contrast" 148 onClick={() => { 149 - if (toolbarState === "multiselect" || toolbarState === "block") { 150 - useUIState.setState({ selectedBlocks: [] }); 151 - rep && focusPage(props.pageID, rep); 152 - } 153 - 154 - if (toolbarState === "default") { 155 useUIState.setState(() => ({ 156 focusedEntity: { 157 entityType: "page", ··· 161 })); 162 } else { 163 setToolbarState("default"); 164 - focusedBlock && keepFocus(focusedBlock.entityID); 165 } 166 }} 167 >
··· 34 35 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 36 37 + let focusedEntity = useUIState((s) => s.focusedEntity); 38 let selectedBlocks = useUIState((s) => s.selectedBlocks); 39 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 40 ··· 146 <button 147 className="toolbarBackToDefault hover:text-accent-contrast" 148 onClick={() => { 149 + if ( 150 + toolbarState === "multiselect" || 151 + toolbarState === "block" || 152 + toolbarState === "default" 153 + ) { 154 useUIState.setState(() => ({ 155 focusedEntity: { 156 entityType: "page", ··· 160 })); 161 } else { 162 setToolbarState("default"); 163 + focusedEntity && keepFocus(focusedEntity.entityID); 164 } 165 }} 166 >
+4
public/gripperPattern.svg
···
··· 1 + <svg width="9" height="5" viewBox="0 0 9 5" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect x="1" y="1" width="2" height="2" rx="1" fill="#8C8C8C"/> 3 + <rect x="6" y="1" width="2" height="2" rx="1" fill="#8C8C8C"/> 4 + </svg>
+4
public/gripperPattern2.svg
···
··· 1 + <svg width="9" height="5" viewBox="0 0 9 5" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="4" height="4" rx="2" fill="#FF5FBF"/> 3 + <rect x="5" width="4" height="4" rx="2" fill="#FF5FBF"/> 4 + </svg>
+101
src/hooks/useDrag.ts
···
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + 3 + export const useDrag = (args: { 4 + onDrag?: (a: {}) => void; 5 + onDragEnd: (d: { x: number; y: number }) => void; 6 + delay?: boolean; 7 + }) => { 8 + let [dragStart, setDragStart] = useState<{ x: number; y: number } | null>( 9 + null, 10 + ); 11 + let timeout = useRef<null | number>(null); 12 + let isLongPress = useRef(false); 13 + 14 + let onTouchStart = useCallback( 15 + (e: React.TouchEvent) => { 16 + if (e.defaultPrevented) return; 17 + if (args.delay) { 18 + isLongPress.current = true; 19 + timeout.current = window.setTimeout(() => { 20 + setDragStart({ x: e.touches[0].clientX, y: e.touches[0].clientY }); 21 + setDragDelta({ x: 0, y: 0 }); 22 + timeout.current = null; 23 + }, 400); 24 + } else { 25 + setDragStart({ x: e.touches[0].clientX, y: e.touches[0].clientY }); 26 + setDragDelta({ x: 0, y: 0 }); 27 + } 28 + }, 29 + [args.delay], 30 + ); 31 + 32 + let onMouseDown = useCallback( 33 + (e: React.MouseEvent) => { 34 + if (e.defaultPrevented) return; 35 + if (args.delay) { 36 + isLongPress.current = true; 37 + timeout.current = window.setTimeout(() => { 38 + timeout.current = null; 39 + }, 400); 40 + } else { 41 + setDragStart({ x: e.clientX, y: e.clientY }); 42 + setDragDelta({ x: 0, y: 0 }); 43 + } 44 + }, 45 + [args.delay], 46 + ); 47 + 48 + let [dragDelta, setDragDelta] = useState<{ 49 + x: number; 50 + y: number; 51 + } | null>(null); 52 + let currentDragDelta = useRef({ x: 0, y: 0 }); 53 + let end = useCallback( 54 + (e: { preventDefault: () => void }) => { 55 + console.log("end???"); 56 + isLongPress.current = false; 57 + if (timeout.current) { 58 + window.clearTimeout(timeout.current); 59 + timeout.current = null; 60 + return; 61 + } 62 + if (args.delay) e.preventDefault(); 63 + console.log(currentDragDelta.current); 64 + args.onDragEnd({ ...currentDragDelta.current }); 65 + currentDragDelta.current = { x: 0, y: 0 }; 66 + setDragStart(null); 67 + setDragDelta(null); 68 + }, 69 + [args.delay, args.onDragEnd], 70 + ); 71 + 72 + useEffect(() => { 73 + if (!dragStart) return; 74 + let disconnect = new AbortController(); 75 + window.addEventListener( 76 + "pointermove", 77 + (e: PointerEvent) => { 78 + currentDragDelta.current.x = e.clientX - dragStart.x; 79 + currentDragDelta.current.y = e.clientY - dragStart.y; 80 + setDragDelta({ ...currentDragDelta.current }); 81 + }, 82 + { signal: disconnect.signal }, 83 + ); 84 + 85 + window.addEventListener( 86 + "contextmenu", 87 + (e) => { 88 + if (isLongPress.current) e.preventDefault(); 89 + }, 90 + { signal: disconnect.signal }, 91 + ); 92 + 93 + window.addEventListener("touchend", end, { signal: disconnect.signal }); 94 + window.addEventListener("pointerup", end, { signal: disconnect.signal }); 95 + return () => { 96 + disconnect.abort(); 97 + }; 98 + }, [dragStart, args, end]); 99 + let handlers = { onMouseDown, onTouchEnd: end, onTouchStart }; 100 + return { dragDelta, handlers }; 101 + };
+35 -19
src/hooks/useLongPress.ts
··· 1 - import { useRef, useEffect, useState } from "react"; 2 3 export const useLongPress = ( 4 cb: () => void, 5 - onMouseDown?: (e: React.MouseEvent) => void, 6 cancel?: boolean, 7 ) => { 8 let longPressTimer = useRef<number>(); 9 let isLongPress = useRef(false); 10 // Change isDown to store the starting position 11 - let [startPosition, setStartPosition] = useState<{ x: number; y: number } | null>(null); 12 13 - let start = (e: React.MouseEvent) => { 14 - onMouseDown && onMouseDown(e); 15 - // Set the starting position 16 - setStartPosition({ x: e.clientX, y: e.clientY }); 17 isLongPress.current = false; 18 longPressTimer.current = window.setTimeout(() => { 19 isLongPress.current = true; 20 cb(); 21 }, 500); 22 - }; 23 24 useEffect(() => { 25 if (startPosition) { 26 let listener = (e: MouseEvent) => { 27 // Calculate the distance moved 28 const distance = Math.sqrt( 29 - Math.pow(e.clientX - startPosition.x, 2) + Math.pow(e.clientY - startPosition.y, 2) 30 ); 31 // Only end if the distance is greater than 10 pixels 32 if (distance > 16) { ··· 38 window.removeEventListener("mousemove", listener); 39 }; 40 } 41 - }, [startPosition]); 42 - 43 - let end = () => { 44 - // Clear the starting position 45 - setStartPosition(null); 46 - window.clearTimeout(longPressTimer.current); 47 - longPressTimer.current = undefined; 48 - }; 49 50 let click = (e: React.MouseEvent | React.PointerEvent) => { 51 if (isLongPress.current) e.preventDefault(); ··· 56 if (cancel) { 57 end(); 58 } 59 - }, [cancel]); 60 61 return { 62 isLongPress: isLongPress, 63 handlers: { 64 - onMouseDown: start, 65 onMouseUp: end, 66 onClickCapture: click, 67 }, 68 };
··· 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 3 export const useLongPress = ( 4 cb: () => void, 5 + propsOnMouseDown?: (e: React.MouseEvent) => void, 6 cancel?: boolean, 7 ) => { 8 let longPressTimer = useRef<number>(); 9 let isLongPress = useRef(false); 10 // Change isDown to store the starting position 11 + let [startPosition, setStartPosition] = useState<{ 12 + x: number; 13 + y: number; 14 + } | null>(null); 15 + 16 + let onMouseDown = useCallback( 17 + (e: React.MouseEvent) => { 18 + propsOnMouseDown && propsOnMouseDown(e); 19 + // Set the starting position 20 + setStartPosition({ x: e.clientX, y: e.clientY }); 21 + isLongPress.current = false; 22 + longPressTimer.current = window.setTimeout(() => { 23 + isLongPress.current = true; 24 + cb(); 25 + }, 500); 26 + }, 27 + [propsOnMouseDown, cb], 28 + ); 29 30 + let onTouchStart = useCallback(() => { 31 isLongPress.current = false; 32 longPressTimer.current = window.setTimeout(() => { 33 isLongPress.current = true; 34 cb(); 35 }, 500); 36 + }, [cb]); 37 38 + let end = useCallback(() => { 39 + // Clear the starting position 40 + setStartPosition(null); 41 + window.clearTimeout(longPressTimer.current); 42 + longPressTimer.current = undefined; 43 + }, []); 44 useEffect(() => { 45 if (startPosition) { 46 let listener = (e: MouseEvent) => { 47 // Calculate the distance moved 48 const distance = Math.sqrt( 49 + Math.pow(e.clientX - startPosition.x, 2) + 50 + Math.pow(e.clientY - startPosition.y, 2), 51 ); 52 // Only end if the distance is greater than 10 pixels 53 if (distance > 16) { ··· 59 window.removeEventListener("mousemove", listener); 60 }; 61 } 62 + }, [startPosition, end]); 63 64 let click = (e: React.MouseEvent | React.PointerEvent) => { 65 if (isLongPress.current) e.preventDefault(); ··· 70 if (cancel) { 71 end(); 72 } 73 + }, [cancel, end]); 74 75 return { 76 isLongPress: isLongPress, 77 handlers: { 78 + onMouseDown, 79 onMouseUp: end, 80 + onTouchStart: onTouchStart, 81 + onTouchEnd: end, 82 onClickCapture: click, 83 }, 84 };
+26
src/replicache/attributes.ts
··· 3 type: "ordered-reference", 4 cardinality: "many", 5 }, 6 } as const; 7 8 const BlockAttributes = { ··· 124 export type Data<A extends keyof typeof Attributes> = { 125 text: { type: "text"; value: string }; 126 string: { type: "string"; value: string }; 127 "ordered-reference": { 128 type: "ordered-reference"; 129 position: string; ··· 150 value: string; 151 }; 152 reference: { type: "reference"; value: string }; 153 "block-type-union": { 154 type: "block-type-union"; 155 value:
··· 3 type: "ordered-reference", 4 cardinality: "many", 5 }, 6 + "page/type": { 7 + type: "page-type-union", 8 + cardinality: "one", 9 + }, 10 + "canvas/block": { 11 + type: "spatial-reference", 12 + cardinality: "many", 13 + }, 14 + "canvas/block/width": { 15 + type: "number", 16 + cardinality: "one", 17 + }, 18 + "canvas/block/rotation": { 19 + type: "number", 20 + cardinality: "one", 21 + }, 22 + "canvas/narrow-width": { 23 + type: "boolean", 24 + cardinality: "one", 25 + }, 26 } as const; 27 28 const BlockAttributes = { ··· 144 export type Data<A extends keyof typeof Attributes> = { 145 text: { type: "text"; value: string }; 146 string: { type: "string"; value: string }; 147 + "spatial-reference": { 148 + type: "spatial-reference"; 149 + position: { x: number; y: number }; 150 + value: string; 151 + }; 152 "ordered-reference": { 153 type: "ordered-reference"; 154 position: string; ··· 175 value: string; 176 }; 177 reference: { type: "reference"; value: string }; 178 + "page-type-union": { type: "page-type-union"; value: "doc" | "canvas" }; 179 "block-type-union": { 180 type: "block-type-union"; 181 value:
+36
src/replicache/mutations.ts
··· 31 32 type Mutation<T> = (args: T, ctx: MutationContext) => Promise<void>; 33 34 const addBlock: Mutation<{ 35 parent: string; 36 permission_set: string; ··· 218 }; 219 220 const addPageLinkBlock: Mutation<{ 221 permission_set: string; 222 blockEntity: string; 223 firstBlockEntity: string; ··· 232 entity: args.blockEntity, 233 attribute: "block/card", 234 data: { type: "reference", value: args.pageEntity }, 235 }); 236 await addBlock( 237 { ··· 490 export const mutations = { 491 retractAttribute, 492 addBlock, 493 addLastBlock, 494 outdentBlock, 495 moveBlockUp,
··· 31 32 type Mutation<T> = (args: T, ctx: MutationContext) => Promise<void>; 33 34 + const addCanvasBlock: Mutation<{ 35 + parent: string; 36 + permission_set: string; 37 + factID: string; 38 + type: Fact<"block/type">["data"]["value"]; 39 + newEntityID: string; 40 + position: { x: number; y: number }; 41 + }> = async (args, ctx) => { 42 + await ctx.createEntity({ 43 + entityID: args.newEntityID, 44 + permission_set: args.permission_set, 45 + }); 46 + await ctx.assertFact({ 47 + entity: args.parent, 48 + id: args.factID, 49 + data: { 50 + type: "spatial-reference", 51 + value: args.newEntityID, 52 + position: args.position, 53 + }, 54 + attribute: "canvas/block", 55 + }); 56 + await ctx.assertFact({ 57 + entity: args.newEntityID, 58 + data: { type: "block-type-union", value: args.type }, 59 + attribute: "block/type", 60 + }); 61 + }; 62 + 63 const addBlock: Mutation<{ 64 parent: string; 65 permission_set: string; ··· 247 }; 248 249 const addPageLinkBlock: Mutation<{ 250 + type: "canvas" | "doc"; 251 permission_set: string; 252 blockEntity: string; 253 firstBlockEntity: string; ··· 262 entity: args.blockEntity, 263 attribute: "block/card", 264 data: { type: "reference", value: args.pageEntity }, 265 + }); 266 + await ctx.assertFact({ 267 + attribute: "page/type", 268 + entity: args.pageEntity, 269 + data: { type: "page-type-union", value: args.type }, 270 }); 271 await addBlock( 272 { ··· 525 export const mutations = { 526 retractAttribute, 527 addBlock, 528 + addCanvasBlock, 529 addLastBlock, 530 outdentBlock, 531 moveBlockUp,
+1 -1
src/replicache/serverMutationContext.ts
··· 150 tx 151 .delete(facts) 152 .where( 153 - driz.sql`(data->>'type' = 'ordered-reference' or data ->>'type' = 'reference') and data->>'value' = ${entity}`, 154 ), 155 ]); 156 },
··· 150 tx 151 .delete(facts) 152 .where( 153 + driz.sql`(data->>'type' = 'ordered-reference' or data ->>'type' = 'reference' or data ->>'type' = 'spatial-reference') and data->>'value' = ${entity}`, 154 ), 155 ]); 156 },
+17 -2
src/replicache/utils.ts
··· 2 import * as driz from "drizzle-orm"; 3 import { Fact } from "."; 4 import { replicache_clients } from "drizzle/schema"; 5 - import { Attributes } from "./attributes"; 6 import { ReadTransaction, WriteTransaction } from "replicache"; 7 8 export function FactWithIndexes(f: Fact<keyof typeof Attributes>) { ··· 14 eav: `${f.entity}-${f.attribute}-${f.id}`, 15 aev: `${f.attribute}-${f.entity}-${f.id}`, 16 }; 17 - if (f.data.type === "reference" || f.data.type === "ordered-reference") 18 indexes.vae = `${f.data.value}-${f.attribute}`; 19 return { ...f, indexes }; 20 } ··· 42 return ( 43 await tx 44 .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 45 .toArray() 46 ).filter((f) => f.attribute === attribute); 47 },
··· 2 import * as driz from "drizzle-orm"; 3 import { Fact } from "."; 4 import { replicache_clients } from "drizzle/schema"; 5 + import { Attributes, FilterAttributes } from "./attributes"; 6 import { ReadTransaction, WriteTransaction } from "replicache"; 7 8 export function FactWithIndexes(f: Fact<keyof typeof Attributes>) { ··· 14 eav: `${f.entity}-${f.attribute}-${f.id}`, 15 aev: `${f.attribute}-${f.entity}-${f.id}`, 16 }; 17 + if ( 18 + f.data.type === "reference" || 19 + f.data.type === "ordered-reference" || 20 + f.data.type === "spatial-reference" 21 + ) 22 indexes.vae = `${f.data.value}-${f.attribute}`; 23 return { ...f, indexes }; 24 } ··· 46 return ( 47 await tx 48 .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 49 + .toArray() 50 + ).filter((f) => f.attribute === attribute); 51 + }, 52 + async vae< 53 + A extends keyof FilterAttributes<{ 54 + type: "reference" | "ordered-reference" | "spatial-reference"; 55 + }>, 56 + >(entity: string, attribute: A) { 57 + return ( 58 + await tx 59 + .scan<Fact<A>>({ indexName: "vae", prefix: `${entity}-${attribute}` }) 60 .toArray() 61 ).filter((f) => f.attribute === attribute); 62 },
+3 -1
src/utils/elementId.ts
··· 2 block: (id: string) => ({ 3 text: `block/${id}/content`, 4 container: `block/${id}/container`, 5 }), 6 page: (id: string) => ({ 7 - container: `card/${id}/container`, 8 }), 9 };
··· 2 block: (id: string) => ({ 3 text: `block/${id}/content`, 4 container: `block/${id}/container`, 5 + input: `block/${id}/input`, 6 }), 7 page: (id: string) => ({ 8 + container: `page/${id}/container`, 9 + canvasScrollArea: `page/${id}/canvasScrollArea`, 10 }), 11 };
+24
supabase/migrations/20240913204647_update_all_facts.sql
···
··· 1 + CREATE OR REPLACE FUNCTION public.get_facts(root uuid) 2 + RETURNS SETOF facts 3 + LANGUAGE sql 4 + AS $function$WITH RECURSIVE all_facts as ( 5 + select 6 + * 7 + from 8 + facts 9 + where 10 + entity = root 11 + union 12 + select 13 + f.* 14 + from 15 + facts f 16 + inner join all_facts f1 on ( 17 + uuid(f1.data ->> 'value') = f.entity 18 + ) where f1.data ->> 'type' = 'reference' or f1.data ->> 'type' = 'ordered-reference' or f1.data ->> 'type' = 'spatial-reference' 19 + ) 20 + select 21 + * 22 + from 23 + all_facts;$function$ 24 + ;