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 21 --list-marker-width: 36px; 22 22 --page-width-unitless: min(624, calc(var(--leaflet-width-unitless) - 12)); 23 23 --page-width-units: min(624px, calc(100vw - 12px)); 24 + 25 + --gripperSVG: url("/gripperPattern.svg"); 26 + --gripperSVG2: url("/gripperPattern2.svg"); 24 27 } 25 28 @media (max-width: 640px) { 26 29 :root {
+22 -1
app/home/LeafletPreview.tsx
··· 7 7 import { useRef, useState } from "react"; 8 8 import { Link } from "react-aria-components"; 9 9 import { useBlocks } from "src/hooks/queries/useBlocks"; 10 - import { PermissionToken } from "src/replicache"; 10 + import { PermissionToken, useEntity } from "src/replicache"; 11 11 import { deleteLeaflet } from "actions/deleteLeaflet"; 12 12 import { removeDocFromHome } from "./storage"; 13 13 import { mutate } from "swr"; 14 14 import useMeasure from "react-use-measure"; 15 15 import { ButtonPrimary } from "components/Buttons"; 16 16 import { LeafletOptions } from "./LeafletOptions"; 17 + import { CanvasContent } from "components/Canvas"; 17 18 18 19 export const LeafletPreview = (props: { 19 20 token: PermissionToken; ··· 56 57 }; 57 58 58 59 const LeafletContent = (props: { entityID: string }) => { 60 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 59 61 let blocks = useBlocks(props.entityID); 60 62 let previewRef = useRef<HTMLDivElement | null>(null); 61 63 let [ref, dimensions] = useMeasure(); 62 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 + 63 83 return ( 64 84 <div 65 85 ref={previewRef} ··· 76 96 {blocks.slice(0, 10).map((b, index, arr) => { 77 97 return ( 78 98 <BlockPreview 99 + pageType="doc" 79 100 entityID={b.value} 80 101 previousBlock={arr[index - 1] || null} 81 102 nextBlock={arr[index + 1] || null}
+3 -1
components/Blocks/Block.tsx
··· 31 31 }; 32 32 }; 33 33 export type BlockProps = { 34 + pageType: Fact<"page/type">["data"]["value"]; 34 35 entityID: string; 35 36 parent: string; 36 37 position: string; ··· 48 49 49 50 // focus block on longpress, shouldnt the type be based on the block type (?) 50 51 let { isLongPress, handlers } = useLongPress(() => { 52 + console.log("wat"); 51 53 if (isLongPress.current) { 52 54 focusBlock( 53 - { type: "card", value: props.entityID, parent: props.parent }, 55 + { type: props.type, value: props.entityID, parent: props.parent }, 54 56 { type: "start" }, 55 57 ); 56 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 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import { useUIState } from "src/useUIState"; 3 3 import { 4 - BlockPageLinkSmall, 4 + BlockDocPageSmall, 5 5 BlockImageSmall, 6 6 BlockLinkSmall, 7 7 CheckTiny, ··· 34 34 35 35 type Props = { 36 36 parent: string; 37 - entityID: string | null; 37 + entityID: string; 38 38 position: string | null; 39 39 nextPosition: string | null; 40 40 factID?: string | undefined; ··· 139 139 } 140 140 let newPage = v7(); 141 141 await rep?.mutate.addPageLinkBlock({ 142 + type: "doc", 142 143 blockEntity: entity, 143 144 firstBlockFactID: v7(), 144 145 firstBlockEntity: v7(), ··· 149 150 if (rep) focusPage(newPage, rep, "focusFirstBlock"); 150 151 }} 151 152 > 152 - <BlockPageLinkSmall /> 153 + <BlockDocPageSmall /> 153 154 </ToolbarButton> 154 155 <ToolbarButton 155 156 tooltipContent="Add a Link"
+136 -2
components/Blocks/ExternalLinkBlock.tsx
··· 1 - import { useEntity } from "src/replicache"; 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"; 2 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"; 3 17 4 - export const ExternalLinkBlock = (props: { entityID: string }) => { 18 + export const ExternalLinkBlock = (props: BlockProps) => { 5 19 let previewImage = useEntity(props.entityID, "link/preview"); 6 20 let title = useEntity(props.entityID, "link/title"); 7 21 let description = useEntity(props.entityID, "link/description"); ··· 10 24 let isSelected = useUIState((s) => 11 25 s.selectedBlocks.find((b) => b.value === props.entityID), 12 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 + } 13 50 14 51 return ( 15 52 <a ··· 26 63 <div className="flex flex-col w-full min-w-0 h-full grow "> 27 64 <div 28 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 + }} 29 71 > 30 72 {title?.data.value} 31 73 </div> ··· 54 96 </a> 55 97 ); 56 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 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { Block } from "./Block"; 4 + import { Block, BlockProps } from "./Block"; 5 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"; 6 14 7 - export function ImageBlock(props: Block) { 15 + export function ImageBlock(props: BlockProps) { 8 16 let { rep } = useReplicache(); 9 17 let image = useEntity(props.value, "block/image"); 18 + let entity_set = useEntitySetContext(); 10 19 let isSelected = useUIState((s) => 11 20 s.selectedBlocks.find((b) => b.value === props.value), 12 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 + } 13 86 14 87 return ( 15 88 <div className="relative group/image flex w-full justify-center">
+63 -9
components/Blocks/PageLinkBlock.tsx
··· 9 9 import { usePageMetadata } from "src/hooks/queries/usePageMetadata"; 10 10 import { CSSProperties, useEffect, useRef, useState } from "react"; 11 11 import { useBlocks } from "src/hooks/queries/useBlocks"; 12 + import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; 12 13 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"; 14 18 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 19 20 let isSelected = useUIState((s) => 20 21 s.selectedBlocks.find((b) => b.value === props.entityID), 21 22 ); 22 23 23 - let isOpen = useUIState((s) => s.openPages).includes(pageEntity); 24 + let isOpen = useUIState((s) => s.openPages).includes(page?.data.value || ""); 24 25 25 26 return ( 26 27 <div 27 - style={{ "--list-marker-width": "20px" } as CSSProperties} 28 - className={` 28 + className={`w-full cursor-pointer 29 29 pageLinkBlockWrapper relative group/pageLinkBlock 30 - w-full h-[104px] 31 30 bg-bg-page border shadow-sm outline outline-1 rounded-lg 32 31 flex overflow-clip 33 32 ${ ··· 38 37 : "border-border-light outline-transparent hover:outline-border-light" 39 38 } 40 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 + `} 41 70 > 42 71 <> 43 72 <div 44 - className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer" 73 + className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full" 45 74 onClick={(e) => { 46 75 if (e.isDefaultPrevented()) return; 47 76 if (e.shiftKey) return; ··· 117 146 {blocks.slice(0, 20).map((b, index, arr) => { 118 147 return ( 119 148 <BlockPreview 149 + pageType="doc" 120 150 entityID={b.value} 121 151 previousBlock={arr[index - 1] || null} 122 152 nextBlock={arr[index + 1] || null} ··· 132 162 ); 133 163 } 134 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 + 135 190 export function BlockPreview( 136 191 b: BlockProps & { 137 192 previewRef: React.RefObject<HTMLDivElement>; 138 - size?: "small" | "large"; 139 193 }, 140 194 ) { 141 195 let ref = useRef<HTMLDivElement | null>(null);
+43 -11
components/Blocks/TextBlock/index.tsx
··· 31 31 import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; 32 32 import { useAppEventListener } from "src/eventBus"; 33 33 import { addLinkBlock } from "src/utils/addLinkBlock"; 34 - import { BlockOptions } from "components/Blocks/BlockOptions"; 34 + import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 35 35 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 36 import { isIOS } from "@react-aria/utils"; 37 37 import { useIsMobile } from "src/hooks/isMobile"; ··· 41 41 import { useHandlePaste } from "./useHandlePaste"; 42 42 import { highlightSelectionPlugin } from "./plugins"; 43 43 import { inputrules } from "./inputRules"; 44 + import { MoreOptionsTiny } from "components/Icons"; 44 45 45 46 export function TextBlock( 46 47 props: BlockProps & { className: string; preview?: boolean }, ··· 148 149 149 150 export function BaseTextBlock(props: BlockProps & { className: string }) { 150 151 const [mount, setMount] = useState<HTMLElement | null>(null); 151 - 152 152 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 153 153 let entity_set = useEntitySetContext(); 154 154 let propsRef = useRef({ ...props, entity_set }); ··· 271 271 className={`${props.className} pointer-events-none absolute top-0 left-0 italic text-tertiary `} 272 272 > 273 273 {props.type === "text" 274 - ? "write something..." 274 + ? 'write something... or type "/"' 275 275 : headingLevel?.data.value === 3 276 276 ? "Subheader" 277 277 : headingLevel?.data.value === 2 ··· 280 280 </div> 281 281 )} 282 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} 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)} 291 323 /> 292 324 )} 293 325 </div>
+43 -8
components/Blocks/TextBlock/keymap.ts
··· 17 17 import { scanIndex } from "src/replicache/utils"; 18 18 import { indent, outdent } from "src/utils/list-operations"; 19 19 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 20 + import { isTextBlock } from "src/utils/isTextBlock"; 20 21 21 22 type PropsRef = MutableRefObject<BlockProps & { entity_set: { set: string } }>; 22 23 export const TextBlockKeymap = ( ··· 101 102 }, 102 103 ArrowUp: (state, _tr, view) => { 103 104 if (!view) return false; 105 + if (state.doc.textContent.startsWith("/")) return true; 104 106 if (useUIState.getState().selectedBlocks.length > 1) return true; 105 107 if (view.state.selection.from !== view.state.selection.to) return false; 106 108 const viewClientRect = view.dom.getBoundingClientRect(); ··· 118 120 }, 119 121 ArrowDown: (state, tr, view) => { 120 122 if (!view) return true; 123 + if (state.doc.textContent.startsWith("/")) return true; 121 124 if (useUIState.getState().selectedBlocks.length > 1) return true; 122 125 if (view.state.selection.from !== view.state.selection.to) return false; 123 126 const viewClientRect = view.dom.getBoundingClientRect(); ··· 232 235 ), 233 236 10, 234 237 ); 238 + 239 + return false; 235 240 } 236 - return false; 237 241 } 238 242 239 - let block = 240 - useEditorStates.getState().editorStates[ 241 - propsRef.current.previousBlock.value 242 - ]; 243 + let block = !!propsRef.current.previousBlock 244 + ? useEditorStates.getState().editorStates[ 245 + propsRef.current.previousBlock.value 246 + ] 247 + : null; 243 248 if ( 244 249 block && 250 + propsRef.current.previousBlock && 245 251 block.editor.doc.textContent.length === 0 && 246 - !propsRef.current.previousBlock.listData 252 + !propsRef.current.previousBlock?.listData 247 253 ) { 248 254 repRef.current?.mutate.removeBlock({ 249 255 blockEntity: propsRef.current.previousBlock.value, ··· 261 267 return true; 262 268 } 263 269 264 - if (propsRef.current.previousBlock.type === "card") { 270 + if ( 271 + propsRef.current.previousBlock && 272 + !isTextBlock[propsRef.current.previousBlock?.type] 273 + ) { 265 274 focusBlock(propsRef.current.previousBlock, { type: "end" }); 266 275 view?.dom.blur(); 267 276 return true; 268 277 } 269 278 270 - if (!block) return false; 279 + if (!block || !propsRef.current.previousBlock) return false; 271 280 272 281 repRef.current?.mutate.removeBlock({ 273 282 blockEntity: propsRef.current.entityID, ··· 318 327 dispatch?: (tr: Transaction) => void, 319 328 view?: EditorView, 320 329 ) => { 330 + if (state.doc.textContent.startsWith("/")) return true; 321 331 let tr = state.tr; 322 332 let newContent = tr.doc.slice(state.selection.anchor); 323 333 tr.delete(state.selection.anchor, state.doc.content.size); ··· 330 340 propsRef.current.type === "heading" && state.selection.anchor <= 2 331 341 ? ("heading" as const) 332 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 + } 333 368 if (propsRef.current.listData) { 334 369 if (state.doc.content.size <= 2) { 335 370 return shifttab(propsRef, repRef)();
+11 -17
components/Blocks/index.tsx
··· 72 72 }, [blocks]); 73 73 74 74 let lastRootBlock = blocks.findLast( 75 - (f) => !f.listData || f.listData.depth === 1 75 + (f) => !f.listData || f.listData.depth === 1, 76 76 ); 77 77 78 78 let lastVisibleBlock = blocks.findLast( 79 79 (f) => 80 80 !f.listData || 81 81 !f.listData.path.find( 82 - (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity 83 - ) 82 + (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, 83 + ), 84 84 ); 85 85 86 86 return ( ··· 102 102 type: "text", 103 103 position: generateKeyBetween( 104 104 lastRootBlock?.position || null, 105 - null 105 + null, 106 106 ), 107 107 newEntityID, 108 108 }); ··· 124 124 !f.listData || 125 125 !f.listData.path.find( 126 126 (path) => 127 - foldedBlocks.includes(path.entity) && f.value !== path.entity 128 - ) 127 + foldedBlocks.includes(path.entity) && f.value !== path.entity, 128 + ), 129 129 ) 130 130 .map((f, index, arr) => { 131 131 let nextBlock = arr[index + 1]; ··· 140 140 } 141 141 return ( 142 142 <Block 143 + pageType="doc" 143 144 {...f} 144 145 key={f.value} 145 146 entityID={f.value} ··· 170 171 let editorState = useEditorStates((s) => 171 172 props.lastBlock?.type === "text" 172 173 ? s.editorStates[props.lastBlock.value] 173 - : null 174 + : null, 174 175 ); 175 176 176 177 if (!entity_set.permissions.write) return null; ··· 192 193 permission_set: entity_set.set, 193 194 position: generateKeyBetween( 194 195 props.lastBlock?.position || null, 195 - null 196 + null, 196 197 ), 197 198 newEntityID, 198 199 }); ··· 210 211 " " 211 212 )} 212 213 </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 214 </div> 221 215 ); 222 216 } ··· 243 237 ) { 244 238 focusBlock( 245 239 { ...props.lastVisibleBlock, type: "text" }, 246 - { type: "end" } 240 + { type: "end" }, 247 241 ); 248 242 } else { 249 243 // else add a new text block at the end and focus it ··· 254 248 type: "text", 255 249 position: generateKeyBetween( 256 250 props.lastRootBlock?.position || null, 257 - null 251 + null, 258 252 ), 259 253 newEntityID, 260 254 });
+18 -10
components/Blocks/useBlockKeyboardHandlers.ts
··· 95 95 async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 96 96 // if this is a textBlock, let the textBlock/keymap handle the backspace 97 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; 98 106 99 107 // if the block is a card or mailbox... 100 108 if (props.type === "card" || props.type === "mailbox") { ··· 107 115 // and the user is not in an input or textarea, 108 116 // if there is a page to close, close it and remove the block 109 117 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 118 return deleteBlock([props.entityID].flat(), rep); 120 119 } 121 120 } ··· 127 126 if (prevBlock) focusBlock(prevBlock, { type: "end" }); 128 127 } 129 128 130 - async function Enter({ props, rep, entity_set }: Args) { 129 + async function Enter({ e, props, rep, entity_set }: Args) { 131 130 let newEntityID = v7(); 132 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 + 133 141 // if it's a list, create a new list item at the same depth 134 142 if (props.listData) { 135 143 let hasChild =
+51
components/Buttons.tsx
··· 1 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"; 2 5 3 6 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 4 7 export function ButtonPrimary( ··· 55 58 </div> 56 59 ); 57 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 3 import { Media } from "./Media"; 4 4 import { Toolbar } from "./Toolbar"; 5 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 + import { focusBlock } from "src/utils/focusBlock"; 6 7 7 8 export function DesktopPageFooter(props: { pageID: string }) { 8 - let focusedBlock = useUIState((s) => s.focusedEntity); 9 + let focusedEntity = useUIState((s) => s.focusedEntity); 9 10 let focusedBlockParentID = 10 - focusedBlock?.entityType === "page" 11 - ? focusedBlock.entityID 12 - : focusedBlock?.parent; 11 + focusedEntity?.entityType === "page" 12 + ? focusedEntity.entityID 13 + : focusedEntity?.parent; 13 14 let entity_set = useEntitySetContext(); 15 + 14 16 return ( 15 17 <Media 16 18 mobile={false} 17 19 className="absolute bottom-4 w-full z-10 pointer-events-none" 18 20 > 19 - {focusedBlock && 20 - focusedBlock.entityType === "block" && 21 + {focusedEntity && 22 + focusedEntity.entityType === "block" && 21 23 entity_set.permissions.write && 22 24 focusedBlockParentID === props.pageID && ( 23 25 <div ··· 28 30 > 29 31 <Toolbar 30 32 pageID={focusedBlockParentID} 31 - blockID={focusedBlock.entityID} 33 + blockID={focusedEntity.entityID} 32 34 /> 33 35 </div> 34 36 )}
+82 -2
components/Icons.tsx
··· 2 2 3 3 type Props = SVGProps<SVGSVGElement>; 4 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 + 5 25 export const HomeMedium = (props: Props) => { 6 26 return ( 7 27 <svg ··· 84 104 ); 85 105 }; 86 106 87 - export const BlockPageLinkSmall = (props: Props) => { 107 + export const BlockDocPageSmall = (props: Props) => { 88 108 return ( 89 109 <svg 90 110 width="24" ··· 104 124 ); 105 125 }; 106 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 + 107 147 export const BlockImageSmall = (props: Props) => { 108 148 return ( 109 149 <svg ··· 118 158 fillRule="evenodd" 119 159 clipRule="evenodd" 120 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" 121 201 fill="currentColor" 122 202 /> 123 203 </svg> ··· 919 999 ) => { 920 1000 return ( 921 1001 <svg 922 - {...props} 1002 + {...{ props, arrowFill: undefined, arrowStroke: undefined }} 923 1003 width="16" 924 1004 height="8" 925 1005 viewBox="0 0 16 8"
+1 -1
components/MobileFooter.tsx
··· 13 13 let entity_set = useEntitySetContext(); 14 14 15 15 return ( 16 - <Media mobile className="mobileFooter w-full z-10 -mt-6 touch-none"> 16 + <Media mobile className="mobileFooter w-full z-10 touch-none"> 17 17 {focusedBlock && 18 18 focusedBlock.entityType == "block" && 19 19 entity_set.permissions.write ? (
+119 -28
components/Pages.tsx
··· 1 1 "use client"; 2 + import { useEffect } from "react"; 2 3 import { useUIState } from "src/useUIState"; 3 - import { Blocks } from "components/Blocks"; 4 + import { useEntitySetContext } from "./EntitySetProvider"; 5 + import { useSearchParams } from "next/navigation"; 6 + import { useToaster } from "./Toast"; 7 + 4 8 import { focusBlock } from "src/utils/focusBlock"; 5 - import useMeasure from "react-use-measure"; 6 9 import { elementId } from "src/utils/elementId"; 7 - import { ThemePopover } from "./ThemeManager/ThemeSetter"; 8 - import { Media } from "./Media"; 9 - import { DesktopPageFooter } from "./DesktopFooter"; 10 + import { theme } from "tailwind.config"; 11 + 10 12 import { Replicache } from "replicache"; 11 13 import { 12 14 Fact, 13 15 ReplicacheMutators, 16 + useEntity, 14 17 useReferenceToEntity, 15 18 useReplicache, 16 19 } 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 + 21 + import { Media } from "./Media"; 22 + import { DesktopPageFooter } from "./DesktopFooter"; 20 23 import { ShareOptions } from "./ShareOptions"; 21 - import { MenuItem, Menu } from "./Layout"; 22 - import { useEntitySetContext } from "./EntitySetProvider"; 24 + import { ThemePopover } from "./ThemeManager/ThemeSetter"; 23 25 import { HomeButton } from "./HomeButton"; 24 - import { useSearchParams } from "next/navigation"; 25 - import { useEffect } from "react"; 26 + import { Canvas } from "./Canvas"; 26 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"; 27 39 import { useIsMobile } from "src/hooks/isMobile"; 28 40 import { HelpPopover } from "./HelpPopover"; 29 41 ··· 42 54 return ( 43 55 <div 44 56 id="pages" 45 - className="pages flex pt-2 pb-8 sm:py-6" 57 + className="pages flex pt-2 pb-1 sm:pb-8 sm:py-6" 46 58 onClick={(e) => { 47 59 e.currentTarget === e.target && blurPage(); 48 60 }} ··· 106 118 : focusedElement?.parent; 107 119 let isFocused = focusedPageID === props.entityID; 108 120 let isMobile = useIsMobile(); 121 + let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 109 122 110 123 return ( 111 124 <> ··· 118 131 /> 119 132 )} 120 133 <div className="pageWrapper w-fit flex relative snap-center"> 134 + {props.first && ( 135 + <SwitchPageTypeButton entityID={props.entityID} pageType={type} /> 136 + )} 137 + 121 138 <div 122 139 onMouseDown={(e) => { 123 140 if (e.defaultPrevented) return; 124 - if (!isMobile) return; 125 141 if (rep) { 126 142 focusPage(props.entityID, rep); 127 143 } ··· 129 145 id={elementId.page(props.entityID).container} 130 146 style={{ 131 147 backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 132 - width: "var(--page-width-units)", 148 + width: type === "doc" ? "var(--page-width-units)" : undefined, 133 149 }} 134 150 className={` 151 + ${type === "canvas" ? "!lg:max-w-[1152px]" : "max-w-[var(--page-width-units)]"} 135 152 page 136 153 grow flex flex-col 137 154 overscroll-y-none ··· 141 158 `} 142 159 > 143 160 <Media mobile={true}> 144 - {!props.first && <PageOptionsMenu entityID={props.entityID} />} 161 + <PageOptionsMenu entityID={props.entityID} first={props.first} /> 145 162 </Media> 146 163 <DesktopPageFooter pageID={props.entityID} /> 147 164 {isDraft.length > 0 && ( ··· 155 172 <DraftPostOptions mailboxEntity={isDraft[0].entity} /> 156 173 </div> 157 174 )} 158 - <Blocks entityID={props.entityID} /> 175 + 176 + <PageContent entityID={props.entityID} /> 159 177 </div> 160 178 <Media mobile={false}> 161 - {isFocused && !props.first && ( 162 - <PageOptionsMenu entityID={props.entityID} /> 179 + {isFocused && ( 180 + <PageOptionsMenu entityID={props.entityID} first={props.first} /> 163 181 )} 164 182 </Media> 165 183 </div> ··· 167 185 ); 168 186 } 169 187 170 - const PageOptionsMenu = (props: { entityID: string }) => { 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 + }) => { 171 198 let permission = useEntitySetContext().permissions.write; 199 + if (!permission) return; 172 200 return ( 173 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"> 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 - }} 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`} 179 248 > 180 - <CloseTiny /> 181 - </button> 182 - {/* {permission && <OptionsMenu/>} */} 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> 183 274 </div> 184 275 ); 185 276 };
+129 -115
components/Toolbar/BlockToolbar.tsx
··· 1 1 import { DeleteSmall, MoveBlockDown, MoveBlockUp } from "components/Icons"; 2 - import { useReplicache } from "src/replicache"; 2 + import { useEntity, useReplicache } from "src/replicache"; 3 3 import { ToolbarButton } from "."; 4 4 import { Separator, ShortcutKey } from "components/Layout"; 5 5 import { metaKey } from "src/utils/metaKey"; ··· 9 9 export const BlockToolbar = (props: { 10 10 setToolbarState: (state: "areYouSure" | "block") => void; 11 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 = () => { 12 41 let { rep } = useReplicache(); 13 - 14 42 const getSortedSelection = async () => { 15 43 let selectedBlocks = useUIState.getState().selectedBlocks; 16 44 let siblings = ··· 22 50 ); 23 51 return [sortedBlocks, siblings]; 24 52 }; 25 - 26 53 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 = 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 = 45 65 siblings?.[ 46 - siblings.findIndex((s) => s.value === block.value) - 1 66 + siblings.findIndex((s) => s.value === block.value) - 2 47 67 ]; 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 68 } 93 - > 94 - <MoveBlockUp /> 95 - </ToolbarButton> 96 69 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> 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> 138 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 + }); 139 143 } 140 - > 141 - <MoveBlockDown /> 142 - </ToolbarButton> 143 - </div> 144 - </div> 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 + </> 145 159 ); 146 160 };
+7 -8
components/Toolbar/index.tsx
··· 34 34 35 35 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 36 36 37 - let focusedBlock = useUIState((s) => s.focusedEntity); 37 + let focusedEntity = useUIState((s) => s.focusedEntity); 38 38 let selectedBlocks = useUIState((s) => s.selectedBlocks); 39 39 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 40 40 ··· 146 146 <button 147 147 className="toolbarBackToDefault hover:text-accent-contrast" 148 148 onClick={() => { 149 - if (toolbarState === "multiselect" || toolbarState === "block") { 150 - useUIState.setState({ selectedBlocks: [] }); 151 - rep && focusPage(props.pageID, rep); 152 - } 153 - 154 - if (toolbarState === "default") { 149 + if ( 150 + toolbarState === "multiselect" || 151 + toolbarState === "block" || 152 + toolbarState === "default" 153 + ) { 155 154 useUIState.setState(() => ({ 156 155 focusedEntity: { 157 156 entityType: "page", ··· 161 160 })); 162 161 } else { 163 162 setToolbarState("default"); 164 - focusedBlock && keepFocus(focusedBlock.entityID); 163 + focusedEntity && keepFocus(focusedEntity.entityID); 165 164 } 166 165 }} 167 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"; 1 + import { useRef, useEffect, useState, useCallback } from "react"; 2 2 3 3 export const useLongPress = ( 4 4 cb: () => void, 5 - onMouseDown?: (e: React.MouseEvent) => void, 5 + propsOnMouseDown?: (e: React.MouseEvent) => void, 6 6 cancel?: boolean, 7 7 ) => { 8 8 let longPressTimer = useRef<number>(); 9 9 let isLongPress = useRef(false); 10 10 // Change isDown to store the starting position 11 - let [startPosition, setStartPosition] = useState<{ x: number; y: number } | null>(null); 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 + ); 12 29 13 - let start = (e: React.MouseEvent) => { 14 - onMouseDown && onMouseDown(e); 15 - // Set the starting position 16 - setStartPosition({ x: e.clientX, y: e.clientY }); 30 + let onTouchStart = useCallback(() => { 17 31 isLongPress.current = false; 18 32 longPressTimer.current = window.setTimeout(() => { 19 33 isLongPress.current = true; 20 34 cb(); 21 35 }, 500); 22 - }; 36 + }, [cb]); 23 37 38 + let end = useCallback(() => { 39 + // Clear the starting position 40 + setStartPosition(null); 41 + window.clearTimeout(longPressTimer.current); 42 + longPressTimer.current = undefined; 43 + }, []); 24 44 useEffect(() => { 25 45 if (startPosition) { 26 46 let listener = (e: MouseEvent) => { 27 47 // Calculate the distance moved 28 48 const distance = Math.sqrt( 29 - Math.pow(e.clientX - startPosition.x, 2) + Math.pow(e.clientY - startPosition.y, 2) 49 + Math.pow(e.clientX - startPosition.x, 2) + 50 + Math.pow(e.clientY - startPosition.y, 2), 30 51 ); 31 52 // Only end if the distance is greater than 10 pixels 32 53 if (distance > 16) { ··· 38 59 window.removeEventListener("mousemove", listener); 39 60 }; 40 61 } 41 - }, [startPosition]); 42 - 43 - let end = () => { 44 - // Clear the starting position 45 - setStartPosition(null); 46 - window.clearTimeout(longPressTimer.current); 47 - longPressTimer.current = undefined; 48 - }; 62 + }, [startPosition, end]); 49 63 50 64 let click = (e: React.MouseEvent | React.PointerEvent) => { 51 65 if (isLongPress.current) e.preventDefault(); ··· 56 70 if (cancel) { 57 71 end(); 58 72 } 59 - }, [cancel]); 73 + }, [cancel, end]); 60 74 61 75 return { 62 76 isLongPress: isLongPress, 63 77 handlers: { 64 - onMouseDown: start, 78 + onMouseDown, 65 79 onMouseUp: end, 80 + onTouchStart: onTouchStart, 81 + onTouchEnd: end, 66 82 onClickCapture: click, 67 83 }, 68 84 };
+26
src/replicache/attributes.ts
··· 3 3 type: "ordered-reference", 4 4 cardinality: "many", 5 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 + }, 6 26 } as const; 7 27 8 28 const BlockAttributes = { ··· 124 144 export type Data<A extends keyof typeof Attributes> = { 125 145 text: { type: "text"; value: string }; 126 146 string: { type: "string"; value: string }; 147 + "spatial-reference": { 148 + type: "spatial-reference"; 149 + position: { x: number; y: number }; 150 + value: string; 151 + }; 127 152 "ordered-reference": { 128 153 type: "ordered-reference"; 129 154 position: string; ··· 150 175 value: string; 151 176 }; 152 177 reference: { type: "reference"; value: string }; 178 + "page-type-union": { type: "page-type-union"; value: "doc" | "canvas" }; 153 179 "block-type-union": { 154 180 type: "block-type-union"; 155 181 value:
+36
src/replicache/mutations.ts
··· 31 31 32 32 type Mutation<T> = (args: T, ctx: MutationContext) => Promise<void>; 33 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 + 34 63 const addBlock: Mutation<{ 35 64 parent: string; 36 65 permission_set: string; ··· 218 247 }; 219 248 220 249 const addPageLinkBlock: Mutation<{ 250 + type: "canvas" | "doc"; 221 251 permission_set: string; 222 252 blockEntity: string; 223 253 firstBlockEntity: string; ··· 232 262 entity: args.blockEntity, 233 263 attribute: "block/card", 234 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 }, 235 270 }); 236 271 await addBlock( 237 272 { ··· 490 525 export const mutations = { 491 526 retractAttribute, 492 527 addBlock, 528 + addCanvasBlock, 493 529 addLastBlock, 494 530 outdentBlock, 495 531 moveBlockUp,
+1 -1
src/replicache/serverMutationContext.ts
··· 150 150 tx 151 151 .delete(facts) 152 152 .where( 153 - driz.sql`(data->>'type' = 'ordered-reference' or data ->>'type' = 'reference') and data->>'value' = ${entity}`, 153 + driz.sql`(data->>'type' = 'ordered-reference' or data ->>'type' = 'reference' or data ->>'type' = 'spatial-reference') and data->>'value' = ${entity}`, 154 154 ), 155 155 ]); 156 156 },
+17 -2
src/replicache/utils.ts
··· 2 2 import * as driz from "drizzle-orm"; 3 3 import { Fact } from "."; 4 4 import { replicache_clients } from "drizzle/schema"; 5 - import { Attributes } from "./attributes"; 5 + import { Attributes, FilterAttributes } from "./attributes"; 6 6 import { ReadTransaction, WriteTransaction } from "replicache"; 7 7 8 8 export function FactWithIndexes(f: Fact<keyof typeof Attributes>) { ··· 14 14 eav: `${f.entity}-${f.attribute}-${f.id}`, 15 15 aev: `${f.attribute}-${f.entity}-${f.id}`, 16 16 }; 17 - if (f.data.type === "reference" || f.data.type === "ordered-reference") 17 + if ( 18 + f.data.type === "reference" || 19 + f.data.type === "ordered-reference" || 20 + f.data.type === "spatial-reference" 21 + ) 18 22 indexes.vae = `${f.data.value}-${f.attribute}`; 19 23 return { ...f, indexes }; 20 24 } ··· 42 46 return ( 43 47 await tx 44 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}` }) 45 60 .toArray() 46 61 ).filter((f) => f.attribute === attribute); 47 62 },
+3 -1
src/utils/elementId.ts
··· 2 2 block: (id: string) => ({ 3 3 text: `block/${id}/content`, 4 4 container: `block/${id}/container`, 5 + input: `block/${id}/input`, 5 6 }), 6 7 page: (id: string) => ({ 7 - container: `card/${id}/container`, 8 + container: `page/${id}/container`, 9 + canvasScrollArea: `page/${id}/canvasScrollArea`, 8 10 }), 9 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 + ;