a tool for shared writing and social publishing

Feature/lock block (#96)

* add super basic locking functionality

* styled a lock state, added a lock icon to the toolbar

* added block locking to non text blocks, add click icon on block to unlock, added state to the tooltip content of the toolbar lock button

* removed button capability from the lock icon in the locked block, focused block when the toolbar lock icon is toggled

* disabled inputs if locked

* disable toolbar if locked

* allow block keyboard handlers to work if text block is locked

* prevent opening are you sure on locked blocks

* add locking multiple selected blocks

---------

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

authored by awarm.space celine and committed by GitHub 640fa691 61e6ac2e

+2
app/globals.css
··· 24 24 25 25 --gripperSVG: url("/gripperPattern.svg"); 26 26 --gripperSVG2: url("/gripperPattern2.svg"); 27 + --hatchSVG: url("/hatchPattern.svg"); 27 28 } 29 + 28 30 @media (max-width: 640px) { 29 31 :root { 30 32 --list-marker-width: 20px;
+60 -15
components/Blocks/Block.tsx
··· 15 15 import { EmbedBlock } from "./EmbedBlock"; 16 16 import { MailboxBlock } from "./MailboxBlock"; 17 17 import { HeadingBlock } from "./HeadingBlock"; 18 - import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; 18 + import { 19 + CheckboxChecked, 20 + CheckboxEmpty, 21 + LockTiny, 22 + UnlockSmall, 23 + UnlockTiny, 24 + } from "components/Icons"; 19 25 import { AreYouSure } from "./DeleteBlock"; 20 26 import { useEntitySetContext } from "components/EntitySetProvider"; 27 + import { Media } from "components/Media"; 21 28 import { useIsMobile } from "src/hooks/isMobile"; 22 29 import { DateTimeBlock } from "./DateTimeBlock"; 23 30 ··· 115 122 setAreYouSure?: (value: boolean) => void; 116 123 }, 117 124 ) => { 118 - // BaseBlock renders the actual block content 125 + // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 119 126 let BlockTypeComponent = BlockTypeComponents[props.type]; 120 127 return ( 121 - <div className="grow flex"> 128 + <div className="blockContentWrapper grow flex gap-2 z-[1]"> 122 129 {props.listData && <ListMarker {...props} />} 123 130 {props.areYouSure ? ( 124 131 <AreYouSure ··· 151 158 }; 152 159 153 160 export const BlockMultiselectIndicator = (props: BlockProps) => { 161 + let { rep } = useReplicache(); 162 + let isMobile = useIsMobile(); 163 + 154 164 let first = props.previousBlock === null; 155 165 156 166 let isMultiselected = useUIState( ··· 159 169 s.selectedBlocks.length > 1, 160 170 ); 161 171 172 + let isSelected = useUIState((s) => 173 + s.selectedBlocks.find((b) => b.value === props.entityID), 174 + ); 175 + let isLocked = useEntity(props.value, "block/is-locked"); 176 + 162 177 let nextBlockSelected = useUIState((s) => 163 178 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 164 179 ); ··· 166 181 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 167 182 ); 168 183 169 - if (isMultiselected) 170 - // not sure what multiselected and selected is doing (?) 184 + if (isMultiselected || (isLocked?.data.value && isSelected)) 185 + // not sure what multiselected and selected classes are doing (?) 186 + // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 187 + 171 188 return ( 172 - <div 173 - className={` 174 - blockSelectionBG multiselected selected 175 - pointer-events-none bg-border-light 176 - absolute right-2 left-2 bottom-0 177 - ${first ? "top-2" : "top-0"} 178 - ${!prevBlockSelected && "rounded-t-md"} 179 - ${!nextBlockSelected && "rounded-b-md"} 180 - `} 181 - /> 189 + <> 190 + <div 191 + className={` 192 + blockSelectionBG multiselected selected 193 + pointer-events-none 194 + bg-border-light 195 + absolute right-2 left-2 bottom-0 196 + ${first ? "top-2" : "top-0"} 197 + ${!prevBlockSelected && "rounded-t-md"} 198 + ${!nextBlockSelected && "rounded-b-md"} 199 + `} 200 + style={ 201 + isLocked?.data.value 202 + ? { 203 + maskImage: "var(--hatchSVG)", 204 + maskRepeat: "repeat repeat", 205 + } 206 + : {} 207 + } 208 + ></div> 209 + {isLocked?.data.value && ( 210 + <div 211 + className={` 212 + blockSelectionLockIndicator z-10 213 + flex items-center 214 + text-border rounded-full 215 + absolute right-3 216 + 217 + ${ 218 + props.type === "heading" || props.type === "text" 219 + ? "top-[6px]" 220 + : "top-0" 221 + }`} 222 + > 223 + <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 224 + </div> 225 + )} 226 + </> 182 227 ); 183 228 }; 184 229
+5 -1
components/Blocks/EmbedBlock.tsx
··· 22 22 let isSelected = useUIState((s) => 23 23 s.selectedBlocks.find((b) => b.value === props.entityID), 24 24 ); 25 + 25 26 useEffect(() => { 26 27 if (props.preview) return; 27 28 let input = document.getElementById(elementId.block(props.entityID).input); ··· 90 91 let isSelected = useUIState((s) => 91 92 s.selectedBlocks.find((b) => b.value === props.entityID), 92 93 ); 94 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 95 + 93 96 let entity_set = useEntitySetContext(); 94 97 let [linkValue, setLinkValue] = useState(""); 95 98 let { rep } = useReplicache(); ··· 139 142 className="w-full grow border-none outline-none bg-transparent " 140 143 placeholder="www.example.com" 141 144 value={linkValue} 145 + disabled={isLocked} 142 146 onChange={(e) => setLinkValue(e.target.value)} 143 147 onKeyDown={(e) => { 144 148 if (e.key === "Backspace" && linkValue === "") { ··· 162 166 /> 163 167 <div className="flex items-center gap-3 "> 164 168 <button 165 - className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 169 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 166 170 onMouseDown={(e) => { 167 171 e.preventDefault(); 168 172 if (!linkValue || linkValue === "") {
+3 -1
components/Blocks/ExternalLinkBlock.tsx
··· 112 112 let isSelected = useUIState((s) => 113 113 s.selectedBlocks.find((b) => b.value === props.entityID), 114 114 ); 115 + let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 115 116 let entity_set = useEntitySetContext(); 116 117 let [linkValue, setLinkValue] = useState(""); 117 118 let { rep } = useReplicache(); ··· 163 164 <Separator /> 164 165 <Input 165 166 type="url" 167 + disabled={isLocked} 166 168 className="w-full grow border-none outline-none bg-transparent " 167 169 placeholder="www.example.com" 168 170 value={linkValue} ··· 190 192 <div className="flex items-center gap-3 "> 191 193 <button 192 194 autoFocus={false} 193 - className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 195 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 194 196 onMouseDown={(e) => { 195 197 e.preventDefault(); 196 198 if (!linkValue || linkValue === "") {
+4 -1
components/Blocks/ImageBlock.tsx
··· 19 19 let isSelected = useUIState((s) => 20 20 s.selectedBlocks.find((b) => b.value === props.value), 21 21 ); 22 + let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 23 + 22 24 useEffect(() => { 23 25 if (props.preview) return; 24 26 let input = document.getElementById(elementId.block(props.entityID).input); ··· 40 42 text-tertiary hover:text-accent-contrast hover:font-bold 41 43 flex flex-auto gap-2 items-center justify-center 42 44 hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 43 - ${isSelected ? "border-2 border-tertiary font-bold" : "border border-border"} 45 + ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 44 46 ${props.pageType === "canvas" && "bg-bg-page"}`} 45 47 onMouseDown={(e) => e.preventDefault()} 46 48 > ··· 49 51 />{" "} 50 52 Upload An Image 51 53 <input 54 + disabled={isLocked} 52 55 className="h-0 w-0" 53 56 type="file" 54 57 accept="image/*"
+7 -3
components/Blocks/TextBlock/index.tsx
··· 46 46 export function TextBlock( 47 47 props: BlockProps & { className?: string; preview?: boolean }, 48 48 ) { 49 + let isLocked = useEntity(props.entityID, "block/is-locked"); 49 50 let initialized = useInitialPageLoad(); 50 51 let first = props.previousBlock === null; 51 52 let permission = useEntitySetContext().permissions.write; 52 53 53 54 return ( 54 55 <> 55 - {(!initialized || !permission || props.preview) && ( 56 + {(!initialized || 57 + !permission || 58 + props.preview || 59 + isLocked?.data.value) && ( 56 60 <RenderedTextBlock 57 61 entityID={props.entityID} 58 62 className={props.className} ··· 60 64 pageType={props.pageType} 61 65 /> 62 66 )} 63 - {permission && !props.preview && ( 67 + {permission && !props.preview && !isLocked?.data.value && ( 64 68 <div 65 69 className={`w-full relative group/text ${!initialized ? "hidden" : ""}`} 66 70 > ··· 137 141 {(props.pageType === "doc" && props.first) || 138 142 props.pageType === "canvas" ? ( 139 143 <div 140 - className={`${props.className} pointer-events-none italic text-tertiary flex flex-col`} 144 + className={`${props.className} pointer-events-none italic text-tertiary flex flex-col `} 141 145 > 142 146 {headingLevel?.data.value === 1 143 147 ? "Title"
+4 -2
components/Blocks/index.tsx
··· 1 1 "use client"; 2 2 3 - import { Fact, useReplicache } from "src/replicache"; 3 + import { Fact, useEntity, useReplicache } from "src/replicache"; 4 4 5 5 import { useUIState } from "src/useUIState"; 6 6 import { useBlocks } from "src/hooks/queries/useBlocks"; ··· 169 169 : null, 170 170 ); 171 171 172 + let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 172 173 if (!entity_set.permissions.write) return null; 173 174 if ( 174 - (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 175 + ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 176 + props.lastBlock?.type === "heading") && 175 177 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 176 178 ) 177 179 return null;
+22 -4
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 23 ) { 24 24 let { rep } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 + let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 26 27 27 28 let isSelected = useUIState((s) => { 28 29 let selectedBlocks = s.selectedBlocks; 29 30 return ( 30 - (!isTextBlock[props.type] || selectedBlocks.length > 1) && 31 + (!isTextBlock[props.type] || selectedBlocks.length > 1 || isLocked) && 31 32 !!s.selectedBlocks.find((b) => b.value === props.entityID) 32 33 ); 33 34 }); ··· 42 43 let command = { Tab, ArrowUp, ArrowDown, Backspace, Enter, Escape }[ 43 44 e.key 44 45 ]; 45 - command?.({ e, props, rep, entity_set, areYouSure, setAreYouSure }); 46 + command?.({ 47 + e, 48 + props, 49 + rep, 50 + entity_set, 51 + areYouSure, 52 + setAreYouSure, 53 + isLocked, 54 + }); 46 55 }; 47 56 window.addEventListener("keydown", listener); 48 57 return () => window.removeEventListener("keydown", listener); 49 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 58 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 50 59 } 51 60 52 61 type Args = { 53 62 e: KeyboardEvent; 63 + isLocked: boolean; 54 64 props: BlockProps; 55 65 rep: Replicache<ReplicacheMutators>; 56 66 entity_set: { set: string }; ··· 93 103 if (!prevBlock) return; 94 104 } 95 105 96 - async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 106 + async function Backspace({ 107 + e, 108 + props, 109 + rep, 110 + areYouSure, 111 + setAreYouSure, 112 + isLocked, 113 + }: Args) { 97 114 // if this is a textBlock, let the textBlock/keymap handle the backspace 115 + if (isLocked) return; 98 116 if (isTextBlock[props.type]) return; 99 117 let el = e.target as HTMLElement; 100 118 if (
+4 -2
components/Canvas.tsx
··· 543 543 className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 544 544 > 545 545 <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 546 + {/* the gripper is two svg's stacked on top of each other. 547 + One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 546 548 <div 547 549 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 548 - style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "space" }} 550 + style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 549 551 /> 550 552 <div 551 553 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 552 - style={{ maskImage: "var(--gripperSVG)", maskRepeat: "space" }} 554 + style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 553 555 /> 554 556 </Media> 555 557 </div>
+80
components/Icons.tsx
··· 686 686 ); 687 687 }; 688 688 689 + export const LockTiny = (props: Props) => { 690 + return ( 691 + <svg 692 + width="16" 693 + height="16" 694 + viewBox="0 0 16 16" 695 + fill="none" 696 + xmlns="http://www.w3.org/2000/svg" 697 + {...props} 698 + > 699 + <path 700 + fillRule="evenodd" 701 + clipRule="evenodd" 702 + d="M8 1.52121C6.18249 1.52121 4.70911 2.99459 4.70911 4.8121V6.51834H4.67383C3.8454 6.51834 3.17383 7.18992 3.17383 8.01834V11.9686C3.17383 13.3493 4.29312 14.4686 5.67383 14.4686H10.3262C11.7069 14.4686 12.8262 13.3493 12.8262 11.9686V8.01834C12.8262 7.18991 12.1546 6.51834 11.3262 6.51834H11.2909V4.8121C11.2909 2.99459 9.8175 1.52121 8 1.52121ZM9.94088 6.51834V4.8121C9.94088 3.74018 9.07192 2.87121 8 2.87121C6.92807 2.87121 6.05911 3.74018 6.05911 4.8121V6.51834H9.94088ZM8.53274 10.603C8.90769 10.4096 9.16406 10.0186 9.16406 9.56766C9.16406 8.9247 8.64284 8.40347 7.99988 8.40347C7.35692 8.40347 6.83569 8.9247 6.83569 9.56766C6.83569 10.0322 7.10778 10.4332 7.50128 10.62L7.30909 12.3318C7.29246 12.48 7.40842 12.6097 7.55753 12.6097H8.44741C8.59476 12.6097 8.71015 12.4829 8.69631 12.3362L8.53274 10.603Z" 703 + fill="currentColor" 704 + /> 705 + </svg> 706 + ); 707 + }; 708 + 709 + export const UnlockTiny = (props: Props) => { 710 + return ( 711 + <svg 712 + width="16" 713 + height="16" 714 + viewBox="0 0 16 16" 715 + fill="none" 716 + xmlns="http://www.w3.org/2000/svg" 717 + {...props} 718 + > 719 + <path 720 + fillRule="evenodd" 721 + clipRule="evenodd" 722 + d="M4.70897 3.77456C4.70897 1.95705 6.18235 0.483673 7.99985 0.483673C9.81736 0.483673 11.2907 1.95705 11.2907 3.77456V6.51831H11.3262C12.1546 6.51831 12.8262 7.18988 12.8262 8.01831V11.9686C12.8262 13.3493 11.7069 14.4686 10.3262 14.4686H5.67381C4.2931 14.4686 3.17381 13.3493 3.17381 11.9686V8.01831C3.17381 7.18988 3.84538 6.51831 4.67381 6.51831H9.94074V3.77456C9.94074 2.70264 9.07178 1.83367 7.99985 1.83367C6.92793 1.83367 6.05897 2.70264 6.05897 3.77456V4.81478C6.05897 5.18757 5.75676 5.48978 5.38397 5.48978C5.01117 5.48978 4.70897 5.18757 4.70897 4.81478V3.77456ZM9.16404 9.56763C9.16404 10.0186 8.90767 10.4096 8.53272 10.603L8.69629 12.3362C8.71013 12.4829 8.59475 12.6097 8.4474 12.6097H7.55751C7.4084 12.6097 7.29244 12.48 7.30908 12.3318L7.50126 10.6199C7.10776 10.4332 6.83567 10.0322 6.83567 9.56763C6.83567 8.92467 7.3569 8.40344 7.99986 8.40344C8.64282 8.40344 9.16404 8.92467 9.16404 9.56763ZM2.99672 2.30807C2.78926 2.12583 2.47334 2.14627 2.29109 2.35374C2.10885 2.5612 2.12929 2.87712 2.33675 3.05937L3.30592 3.91072C3.51339 4.09297 3.82931 4.07253 4.01155 3.86506C4.1938 3.6576 4.17335 3.34168 3.96589 3.15943L2.99672 2.30807ZM0.346848 3.98126C0.426769 3.71693 0.705835 3.56744 0.970159 3.64736L2.98538 4.25669C3.24971 4.33661 3.39919 4.61567 3.31927 4.88C3.23935 5.14432 2.96029 5.29381 2.69596 5.21389L0.68074 4.60457C0.416415 4.52465 0.266927 4.24558 0.346848 3.98126ZM1.36125 5.86881C1.08858 5.91246 0.902922 6.16889 0.946575 6.44156C0.990227 6.71423 1.24666 6.89988 1.51933 6.85623L2.79161 6.65255C3.06428 6.6089 3.24993 6.35247 3.20628 6.0798C3.16263 5.80713 2.9062 5.62147 2.63353 5.66512L1.36125 5.86881Z" 723 + fill="currentColor" 724 + /> 725 + </svg> 726 + ); 727 + }; 728 + 689 729 export const LogoTiny = ( 690 730 props: Props & { fillColor: string; strokeColor: string }, 691 731 ) => { ··· 1189 1229 fillRule="evenodd" 1190 1230 clipRule="evenodd" 1191 1231 d="M19.2777 7.26811C19.6649 6.79541 19.5956 6.09831 19.1229 5.7111C18.6502 5.32389 17.9531 5.3932 17.5658 5.86591L11.0608 13.8073L8.55229 11.4827C8.10409 11.0674 7.40406 11.094 6.98873 11.5422C6.57339 11.9904 6.60003 12.6905 7.04823 13.1058L10.4194 16.2298C10.6432 16.4372 10.9428 16.543 11.2472 16.5221C11.5517 16.5012 11.834 16.3554 12.0273 16.1194L19.2777 7.26811ZM5.72192 5.78943C4.61735 5.78943 3.72192 6.68486 3.72192 7.78943V17.2894C3.72192 18.394 4.61735 19.2894 5.72192 19.2894H15.2219C16.3265 19.2894 17.2219 18.394 17.2219 17.2894V14.4884C17.2219 14.0741 16.8861 13.7384 16.4719 13.7384C16.0577 13.7384 15.7219 14.0741 15.7219 14.4884V17.2894C15.7219 17.5656 15.4981 17.7894 15.2219 17.7894H5.72192C5.44578 17.7894 5.22192 17.5656 5.22192 17.2894V7.78943C5.22192 7.51329 5.44578 7.28943 5.72192 7.28943H12.9815C13.3957 7.28943 13.7315 6.95364 13.7315 6.53943C13.7315 6.12522 13.3957 5.78943 12.9815 5.78943H5.72192Z" 1232 + fill="currentColor" 1233 + /> 1234 + </svg> 1235 + ); 1236 + }; 1237 + 1238 + export const LockSmall = (props: Props) => { 1239 + return ( 1240 + <svg 1241 + width="24" 1242 + height="24" 1243 + viewBox="0 0 24 24" 1244 + fill="none" 1245 + xmlns="http://www.w3.org/2000/svg" 1246 + {...props} 1247 + > 1248 + <path 1249 + fillRule="evenodd" 1250 + clipRule="evenodd" 1251 + d="M12 3.9657C9.73217 3.9657 7.89374 5.80413 7.89374 8.07196V10.1794H7.78851C6.82201 10.1794 6.03851 10.9629 6.03851 11.9294V17C6.03851 18.6569 7.38166 20 9.03851 20H14.9615C16.6184 20 17.9615 18.6569 17.9615 17V11.9294C17.9615 10.9629 17.178 10.1794 16.2115 10.1794H16.1063V8.07196C16.1063 5.80413 14.2678 3.9657 12 3.9657ZM14.3563 10.1794V8.07196C14.3563 6.77063 13.3013 5.7157 12 5.7157C10.6987 5.7157 9.64374 6.77063 9.64374 8.07196V10.1794H14.3563ZM12.5824 15.3512C12.9924 15.1399 13.2727 14.7123 13.2727 14.2193C13.2727 13.5165 12.7029 12.9467 12 12.9467C11.2972 12.9467 10.7274 13.5165 10.7274 14.2193C10.7274 14.7271 11.0247 15.1654 11.4548 15.3696L11.2418 17.267C11.2252 17.4152 11.3411 17.5449 11.4902 17.5449H12.5147C12.6621 17.5449 12.7774 17.4181 12.7636 17.2714L12.5824 15.3512Z" 1252 + fill="currentColor" 1253 + /> 1254 + </svg> 1255 + ); 1256 + }; 1257 + 1258 + export const UnlockSmall = (props: Props) => { 1259 + return ( 1260 + <svg 1261 + width="24" 1262 + height="24" 1263 + viewBox="0 0 24 24" 1264 + fill="none" 1265 + xmlns="http://www.w3.org/2000/svg" 1266 + {...props} 1267 + > 1268 + <path 1269 + fillRule="evenodd" 1270 + clipRule="evenodd" 1271 + d="M7.89376 6.62482C7.89376 4.35699 9.7322 2.51855 12 2.51855C14.2678 2.51855 16.1063 4.35699 16.1063 6.62482V10.1794H16.2115C17.178 10.1794 17.9615 10.9629 17.9615 11.9294V17C17.9615 18.6569 16.6184 20 14.9615 20H9.03854C7.38168 20 6.03854 18.6569 6.03854 17V11.9294C6.03854 10.9629 6.82204 10.1794 7.78854 10.1794H14.3563V6.62482C14.3563 5.32349 13.3013 4.26855 12 4.26855C10.6987 4.26855 9.64376 5.32349 9.64376 6.62482V7.72078C9.64376 8.20403 9.25201 8.59578 8.76876 8.59578C8.28551 8.59578 7.89376 8.20403 7.89376 7.72078V6.62482ZM13.1496 14.2193C13.1496 14.7123 12.8693 15.1399 12.4593 15.3512L12.6405 17.2714C12.6544 17.4181 12.539 17.5449 12.3916 17.5449H11.3672C11.218 17.5449 11.1021 17.4152 11.1187 17.267L11.3317 15.3696C10.9016 15.1654 10.6043 14.7271 10.6043 14.2193C10.6043 13.5165 11.1741 12.9467 11.8769 12.9467C12.5798 12.9467 13.1496 13.5165 13.1496 14.2193ZM5.62896 5.3862C5.4215 5.20395 5.10558 5.2244 4.92333 5.43186C4.74109 5.63932 4.76153 5.95525 4.969 6.13749L6.06209 7.09771C6.26955 7.27996 6.58548 7.25951 6.76772 7.05205C6.94997 6.84458 6.92952 6.52866 6.72206 6.34642L5.62896 5.3862ZM3.5165 6.64283C3.25418 6.55657 2.97159 6.69929 2.88533 6.96161C2.79906 7.22393 2.94178 7.50652 3.20411 7.59278L5.54822 8.36366C5.81054 8.44992 6.09313 8.3072 6.1794 8.04488C6.26566 7.78256 6.12294 7.49997 5.86062 7.41371L3.5165 6.64283ZM3.54574 9.42431C3.52207 9.14918 3.72592 8.90696 4.00105 8.8833L5.52254 8.75244C5.79766 8.72878 6.03988 8.93263 6.06354 9.20776C6.08721 9.48288 5.88335 9.7251 5.60823 9.74876L4.08674 9.87962C3.81162 9.90329 3.5694 9.69943 3.54574 9.42431Z" 1192 1272 fill="currentColor" 1193 1273 /> 1194 1274 </svg>
+8 -3
components/Toolbar/BlockToolbar.tsx
··· 5 5 import { metaKey } from "src/utils/metaKey"; 6 6 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 7 import { useUIState } from "src/useUIState"; 8 + import { LockBlockButton } from "./LockBlockButton"; 8 9 9 10 export const BlockToolbar = (props: { 10 11 setToolbarState: (state: "areYouSure" | "block") => void; ··· 28 29 > 29 30 <DeleteSmall /> 30 31 </ToolbarButton> 31 - 32 + <Separator classname="h-6" /> 32 33 {focusedEntityType?.data.value !== "canvas" ? ( 33 - <MoveBlockButtons /> 34 + <> 35 + <MoveBlockButtons /> 36 + <Separator classname="h-6" /> 37 + </> 34 38 ) : null} 39 + 40 + <LockBlockButton /> 35 41 </div> 36 42 </div> 37 43 ); ··· 52 58 }; 53 59 return ( 54 60 <> 55 - <Separator classname="h-5" /> 56 61 <ToolbarButton 57 62 onClick={async () => { 58 63 let [sortedBlocks, siblings] = await getSortedSelection();
+62
components/Toolbar/LockBlockButton.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { ToolbarButton } from "."; 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { LockSmall, UnlockSmall } from "components/Icons"; 5 + import { focusBlock } from "src/utils/focusBlock"; 6 + 7 + export function LockBlockButton() { 8 + let focusedBlock = useUIState((s) => s.focusedEntity); 9 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 10 + let type = useEntity(focusedBlock?.entityID || null, "block/type"); 11 + let locked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 12 + let { rep } = useReplicache(); 13 + if (focusedBlock?.entityType !== "block") return; 14 + return ( 15 + <ToolbarButton 16 + disabled={false} 17 + onClick={async () => { 18 + if (!locked?.data.value) { 19 + await rep?.mutate.assertFact({ 20 + entity: focusedBlock.entityID, 21 + attribute: "block/is-locked", 22 + data: { value: true, type: "boolean" }, 23 + }); 24 + if (selectedBlocks.length > 1) { 25 + for (let block of selectedBlocks) { 26 + await rep?.mutate.assertFact({ 27 + attribute: "block/is-locked", 28 + entity: block.value, 29 + data: { value: true, type: "boolean" }, 30 + }); 31 + } 32 + } 33 + } else { 34 + await rep?.mutate.retractFact({ factID: locked.id }); 35 + if (selectedBlocks.length > 1) { 36 + for (let block of selectedBlocks) { 37 + await rep?.mutate.retractAttribute({ 38 + attribute: "block/is-locked", 39 + entity: block.value, 40 + }); 41 + } 42 + } else { 43 + type && 44 + focusBlock( 45 + { 46 + type: type.data.value, 47 + parent: focusedBlock.parent, 48 + value: focusedBlock.entityID, 49 + }, 50 + { type: "end" }, 51 + ); 52 + } 53 + } 54 + }} 55 + tooltipContent={ 56 + <span>{!locked?.data.value ? "Lock Editing" : " Unlock to Edit"}</span> 57 + } 58 + > 59 + {!locked?.data.value ? <LockSmall /> : <UnlockSmall />} 60 + </ToolbarButton> 61 + ); 62 + }
+3
components/Toolbar/MultiSelectToolbar.tsx
··· 8 8 import { useSmoker } from "components/Toast"; 9 9 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 10 import { Replicache } from "replicache"; 11 + import { LockBlockButton } from "./LockBlockButton"; 11 12 12 13 export const MultiselectToolbar = (props: { 13 14 setToolbarState: (state: "areYouSure" | "multiselect") => void; ··· 37 38 <TrashSmall /> 38 39 </ToolbarButton> 39 40 <ToolbarButton 41 + disabled={false} 40 42 tooltipContent="Copy Selected Blocks" 41 43 onClick={handleCopy} 42 44 > 43 45 <CopySmall /> 44 46 </ToolbarButton> 47 + <LockBlockButton /> 45 48 {/* Add more multi-select toolbar buttons here */} 46 49 </div> 47 50 </div>
+13 -8
components/Toolbar/TextToolbar.tsx
··· 10 10 import { ToolbarTypes } from "."; 11 11 import { schema } from "components/Blocks/TextBlock/schema"; 12 12 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 13 + import { LockBlockButton } from "./LockBlockButton"; 13 14 14 15 export const TextToolbar = (props: { 15 16 lastUsedHighlight: string; ··· 31 32 icon={<BoldSmall />} 32 33 /> 33 34 <TextDecorationButton 34 - tooltipContent=<div className="flex flex-col gap-1 justify-center"> 35 - <div className="italic font-normal text-center">Italic</div> 36 - <div className="flex gap-1"> 37 - <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 38 - <ShortcutKey> I </ShortcutKey> 35 + tooltipContent={ 36 + <div className="flex flex-col gap-1 justify-center"> 37 + <div className="italic font-normal text-center">Italic</div> 38 + <div className="flex gap-1"> 39 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 40 + <ShortcutKey> I </ShortcutKey> 41 + </div> 39 42 </div> 40 - </div> 43 + } 41 44 mark={schema.marks.em} 42 45 icon={<ItalicSmall />} 43 46 /> ··· 72 75 setToolbarState={props.setToolbarState} 73 76 /> 74 77 <Separator classname="h-6" /> 78 + <LinkButton setToolbarState={props.setToolbarState} /> 79 + <Separator classname="h-6" /> 75 80 <TextBlockTypeButton setToolbarState={props.setToolbarState} /> 76 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 77 - <Separator classname="h-6" /> 78 82 <ListButton setToolbarState={props.setToolbarState} /> 79 83 <Separator classname="h-6" /> 80 - <LinkButton setToolbarState={props.setToolbarState} /> 84 + 85 + <LockBlockButton /> 81 86 </> 82 87 ); 83 88 };
+12 -7
components/Toolbar/index.tsx
··· 82 82 83 83 return ( 84 84 <Tooltip.Provider> 85 - <div className="toolbar flex items-center justify-between w-full gap-6"> 86 - <div className="toolbarOptions flex gap-[6px] items-center grow"> 85 + <div className="toolbar flex items-center justify-between w-full gap-6 h-[26px]"> 86 + <div className="toolbarOptions flex gap-1 sm:gap-[6px] items-center grow"> 87 87 {toolbarState === "default" ? ( 88 88 <TextToolbar 89 89 lastUsedHighlight={lastUsedHighlight} ··· 180 180 active?: boolean; 181 181 disabled?: boolean; 182 182 }) => { 183 + let focusedBlock = useUIState((s) => s.focusedEntity); 184 + let isLocked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 185 + let isDisabled = 186 + props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 187 + 183 188 return ( 184 189 <TooltipButton 185 190 onMouseDown={(e) => { 186 191 e.preventDefault(); 187 192 props.onClick && props.onClick(e); 188 193 }} 189 - disabled={props.disabled} 194 + disabled={isDisabled} 190 195 content={props.tooltipContent} 191 196 className={` 192 - flex items-center rounded-md border border-transparent hover:border-border active:bg-border-light active:text-primary 197 + flex items-center rounded-md border border-transparent 193 198 ${props.className} 194 199 ${ 195 - props.active 200 + props.active && !isDisabled 196 201 ? "bg-border-light text-primary" 197 - : props.disabled 202 + : isDisabled 198 203 ? "text-border cursor-not-allowed" 199 - : "text-secondary hover:text-primary" 204 + : "text-secondary hover:text-primary hover:border-border active:bg-border-light active:text-primary" 200 205 } 201 206 `} 202 207 >
+8
public/hatchPattern.svg
··· 1 + <!-- <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path fill-rule="evenodd" clip-rule="evenodd" d="M1.41418 0H0V1.41418L1.41418 0ZM0 5.41418V4L4 0H5.41418L0 5.41418ZM0 9.41418V8L8 0H9.41418L0 9.41418ZM0 13.4142V12L12 0H13.4142L0 13.4142ZM0 17.4142V16L16 0H17.4142L0 17.4142ZM0 21.4142V20L20 0H21.4142L0 21.4142ZM0 25.4142V24L24 0H25.4142L0 25.4142ZM0 29.4142V28L28 0H29.4142L0 29.4142ZM1.41418 32H0L32 0V1.41418L1.41418 32ZM5.41418 32H3.99997L32 4V5.41418L5.41418 32ZM9.41418 32H7.99997L32 8V9.41418L9.41418 32ZM13.4142 32H12L32 12V13.4142L13.4142 32ZM17.4142 32H16L32 16V17.4142L17.4142 32ZM21.4142 32H20L32 20V21.4142L21.4142 32ZM25.4142 32H24L32 24V25.4142L25.4142 32ZM29.4142 32H28L32 28V29.4142L29.4142 32Z" fill="black"/> 3 + </svg> --> 4 + 5 + <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> 6 + <path fill-rule="evenodd" clip-rule="evenodd" d="M1.41421 0H0V1.41421L1.41421 0ZM0 7.4142V5.99999L5.99999 0H7.4142L0 7.4142ZM0 13.4142V12L12 0H13.4142L0 13.4142ZM0 19.4142V18L18 0H19.4142L0 19.4142ZM0 25.4142V24L24 0H25.4142L0 25.4142ZM1.4142 30H0L30 0V1.4142L1.4142 30ZM7.4142 30H5.99998L30 5.99999V7.4142L7.4142 30ZM13.4142 30H12L30 12V13.4142L13.4142 30ZM19.4142 30H18L30 18V19.4142L19.4142 30ZM25.4142 30H24L30 24V25.4142L25.4142 30Z" fill="black"/> 7 + </svg> 8 +
+4
src/replicache/attributes.ts
··· 44 44 type: "boolean", 45 45 cardinality: "one", 46 46 }, 47 + "block/is-locked": { 48 + type: "boolean", 49 + cardinality: "one", 50 + }, 47 51 "block/check-list": { 48 52 type: "boolean", 49 53 cardinality: "one",
+5
src/replicache/mutations.ts
··· 289 289 { blockEntity: string } | { blockEntity: string }[] 290 290 > = async (args, ctx) => { 291 291 for (let block of [args].flat()) { 292 + let [isLocked] = await ctx.scanIndex.eav( 293 + block.blockEntity, 294 + "block/is-locked", 295 + ); 296 + if (isLocked?.data.value) continue; 292 297 let images = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 293 298 ctx.runOnServer(async ({ supabase }) => { 294 299 for (let image of images) {
+6 -6
src/utils/focusBlock.ts
··· 8 8 block: Pick<Block, "type" | "value" | "parent">, 9 9 position: Position, 10 10 ) { 11 + useUIState.getState().setSelectedBlock(block); 12 + useUIState.getState().setFocusedBlock({ 13 + entityType: "block", 14 + entityID: block.value, 15 + parent: block.parent, 16 + }); 11 17 if (block.type !== "text" && block.type !== "heading") { 12 - useUIState.getState().setSelectedBlock(block); 13 - useUIState.getState().setFocusedBlock({ 14 - entityType: "block", 15 - entityID: block.value, 16 - parent: block.parent, 17 - }); 18 18 return true; 19 19 } 20 20 let nextBlockID = block.value;