a tool for shared writing and social publishing

Lock is GONE YO

+24 -196
-1
.github/pull_request_template.md
··· 2 2 - it looks good on both mobile and desktop 3 3 - it undo's like it ought to 4 4 - it handles keyboard interactions reasonably well 5 - - it behaves as you would expect if you lock it 6 5 - no build errors!!!
+2 -31
components/Blocks/Block.tsx
··· 334 334 let isSelected = useUIState((s) => 335 335 s.selectedBlocks.find((b) => b.value === props.entityID), 336 336 ); 337 - let isLocked = useEntity(props.value, "block/is-locked"); 338 337 339 338 let nextBlockSelected = useUIState((s) => 340 339 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), ··· 343 342 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 344 343 ); 345 344 346 - if (isMultiselected || (isLocked?.data.value && isSelected)) 347 - // not sure what multiselected and selected classes are doing (?) 348 - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 349 - 345 + if (isMultiselected) 350 346 return ( 351 347 <> 352 348 <div ··· 359 355 ${!prevBlockSelected && "rounded-t-md"} 360 356 ${!nextBlockSelected && "rounded-b-md"} 361 357 `} 362 - style={ 363 - isLocked?.data.value 364 - ? { 365 - maskImage: "var(--hatchSVG)", 366 - maskRepeat: "repeat repeat", 367 - } 368 - : {} 369 - } 370 - ></div> 371 - {isLocked?.data.value && ( 372 - <div 373 - className={` 374 - blockSelectionLockIndicator z-10 375 - flex items-center 376 - text-border rounded-full 377 - absolute right-3 378 - 379 - ${ 380 - props.type === "heading" || props.type === "text" 381 - ? "top-[6px]" 382 - : "top-0" 383 - }`} 384 - > 385 - <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 386 - </div> 387 - )} 358 + /> 388 359 </> 389 360 ); 390 361 };
+1 -3
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 18 18 let isSelected = useUIState((s) => 19 19 s.selectedBlocks.find((b) => b.value === props.entityID), 20 20 ); 21 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 22 21 23 22 let entity_set = useEntitySetContext(); 24 23 let [urlValue, setUrlValue] = useState(""); ··· 91 90 className="w-full grow border-none outline-hidden bg-transparent " 92 91 placeholder="bsky.app/post-url" 93 92 value={urlValue} 94 - disabled={isLocked} 95 93 onChange={(e) => setUrlValue(e.target.value)} 96 94 onKeyDown={(e) => { 97 95 if (e.key === "Enter") { ··· 109 107 <button 110 108 type="submit" 111 109 id="bluesky-post-block-submit" 112 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 110 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 113 111 onMouseDown={(e) => { 114 112 e.preventDefault(); 115 113 errorSmokers(e.clientX + 12, e.clientY);
+1 -4
components/Blocks/ButtonBlock.tsx
··· 56 56 let isSelected = useUIState((s) => 57 57 s.selectedBlocks.find((b) => b.value === props.entityID), 58 58 ); 59 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 60 59 61 60 let [textValue, setTextValue] = useState(""); 62 61 let [urlValue, setUrlValue] = useState(""); ··· 191 190 className="w-full grow border-none outline-hidden bg-transparent" 192 191 placeholder="button text" 193 192 value={textValue} 194 - disabled={isLocked} 195 193 onChange={(e) => setTextValue(e.target.value)} 196 194 onKeyDown={(e) => { 197 195 if ( ··· 214 212 className="w-full grow border-none outline-hidden bg-transparent" 215 213 placeholder="www.example.com" 216 214 value={urlValue} 217 - disabled={isLocked} 218 215 onChange={(e) => setUrlValue(e.target.value)} 219 216 onKeyDown={(e) => { 220 217 if (e.key === "Backspace" && !e.currentTarget.value) ··· 225 222 <button 226 223 id="button-block-settings" 227 224 type="submit" 228 - className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected && !isLocked ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 225 + className={`p-1 shrink-0 w-fit flex gap-2 items-center place-self-end ${isSelected ? "text-accent-contrast" : "text-accent-contrast sm:text-border"}`} 229 226 > 230 227 <div className="sm:hidden block">Save</div> 231 228 <CheckTiny />
+2 -3
components/Blocks/DateTimeBlock.tsx
··· 54 54 s.selectedBlocks.find((b) => b.value === props.entityID), 55 55 ); 56 56 57 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 58 57 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value; 59 58 60 59 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ··· 118 117 119 118 return ( 120 119 <Popover 121 - disabled={isLocked || !permissions.write} 120 + disabled={!permissions.write} 122 121 className="w-64 z-10 px-2!" 123 122 trigger={ 124 123 <BlockLayout ··· 134 133 {dateFact ? ( 135 134 <div 136 135 className={`font-bold 137 - ${!permissions.write || isLocked ? "" : "group-hover/date:underline"} 136 + ${!permissions.write ? "" : "group-hover/date:underline"} 138 137 `} 139 138 > 140 139 {selectedDate.toLocaleDateString(undefined, {
+1 -3
components/Blocks/EmbedBlock.tsx
··· 129 129 let isSelected = useUIState((s) => 130 130 s.selectedBlocks.find((b) => b.value === props.entityID), 131 131 ); 132 - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 133 132 134 133 let entity_set = useEntitySetContext(); 135 134 let [linkValue, setLinkValue] = useState(""); ··· 250 249 className="w-full grow border-none outline-hidden bg-transparent " 251 250 placeholder="www.example.com" 252 251 value={linkValue} 253 - disabled={isLocked} 254 252 onChange={(e) => setLinkValue(e.target.value)} 255 253 /> 256 254 <button 257 255 type="submit" 258 256 id="embed-block-submit" 259 257 disabled={loading} 260 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 258 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 261 259 onMouseDown={(e) => { 262 260 e.preventDefault(); 263 261 if (loading) return;
+1 -3
components/Blocks/ExternalLinkBlock.tsx
··· 118 118 let isSelected = useUIState((s) => 119 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 120 ); 121 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 122 121 let entity_set = useEntitySetContext(); 123 122 let [linkValue, setLinkValue] = useState(""); 124 123 let { rep } = useReplicache(); ··· 173 172 !props.preview ? elementId.block(props.entityID).input : undefined 174 173 } 175 174 type="url" 176 - disabled={isLocked} 177 175 className="w-full grow border-none outline-hidden bg-transparent " 178 176 placeholder="www.example.com" 179 177 value={linkValue} ··· 199 197 <div className="flex items-center gap-3 "> 200 198 <button 201 199 autoFocus={false} 202 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 200 + className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 203 201 onMouseDown={(e) => { 204 202 e.preventDefault(); 205 203 if (!linkValue || linkValue === "") {
+1 -4
components/Blocks/ImageBlock.tsx
··· 28 28 let isSelected = useUIState((s) => 29 29 s.selectedBlocks.find((b) => b.value === props.value), 30 30 ); 31 - let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 31 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 32 let isFirst = props.previousBlock === null; 34 33 let isLast = props.nextBlock === null; ··· 84 83 return ( 85 84 <BlockLayout 86 85 hasBackground="accent" 87 - isSelected={!!isSelected && !isLocked} 86 + isSelected={!!isSelected} 88 87 borderOnHover 89 88 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 89 > ··· 101 100 onDrop={async (e) => { 102 101 e.preventDefault(); 103 102 e.stopPropagation(); 104 - if (isLocked) return; 105 103 const files = e.dataTransfer.files; 106 104 if (files && files.length > 0) { 107 105 const file = files[0]; ··· 118 116 Upload An Image 119 117 </div> 120 118 <input 121 - disabled={isLocked} 122 119 className="h-0 w-0 hidden" 123 120 type="file" 124 121 accept="image/*"
+2 -8
components/Blocks/TextBlock/index.tsx
··· 41 41 preview?: boolean; 42 42 }, 43 43 ) { 44 - let isLocked = useEntity(props.entityID, "block/is-locked"); 45 44 let initialized = useHasPageLoaded(); 46 45 let first = props.previousBlock === null; 47 46 let permission = useEntitySetContext().permissions.write; 48 47 49 48 return ( 50 49 <> 51 - {(!initialized || 52 - !permission || 53 - props.preview || 54 - isLocked?.data.value) && ( 50 + {(!initialized || !permission || props.preview) && ( 55 51 <RenderedTextBlock 56 52 type={props.type} 57 53 entityID={props.entityID} ··· 61 57 previousBlock={props.previousBlock} 62 58 /> 63 59 )} 64 - {permission && !props.preview && !isLocked?.data.value && ( 60 + {permission && !props.preview && ( 65 61 <div 66 62 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 67 63 > ··· 330 326 let { editorState } = props; 331 327 let rep = useReplicache(); 332 328 let smoker = useSmoker(); 333 - let isLocked = useEntity(props.entityID, "block/is-locked"); 334 329 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 335 330 336 331 let isBlueskyPost = ··· 340 335 // if its bluesky, change text to embed post 341 336 342 337 if ( 343 - !isLocked && 344 338 focused && 345 339 editorState && 346 340 betterIsUrl(editorState.doc.textContent) &&
+1 -3
components/Blocks/index.tsx
··· 181 181 : null, 182 182 ); 183 183 184 - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 185 184 if (!entity_set.permissions.write) return null; 186 185 if ( 187 - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 188 - props.lastBlock?.type === "heading") && 186 + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && 189 187 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 190 188 ) 191 189 return null;
+2 -13
components/Blocks/useBlockKeyboardHandlers.ts
··· 23 23 ) { 24 24 let { rep, undoManager } = useReplicache(); 25 25 let entity_set = useEntitySetContext(); 26 - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; 27 26 28 27 let isSelected = useUIState((s) => { 29 28 let selectedBlocks = s.selectedBlocks; ··· 70 69 entity_set, 71 70 areYouSure, 72 71 setAreYouSure, 73 - isLocked, 74 72 }); 75 73 undoManager.endGroup(); 76 74 }; 77 75 window.addEventListener("keydown", listener); 78 76 return () => window.removeEventListener("keydown", listener); 79 - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); 77 + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); 80 78 } 81 79 82 80 type Args = { 83 81 e: KeyboardEvent; 84 - isLocked: boolean; 85 82 props: BlockProps; 86 83 rep: Replicache<ReplicacheMutators>; 87 84 entity_set: { set: string }; ··· 133 130 } 134 131 135 132 let debounced: null | number = null; 136 - async function Backspace({ 137 - e, 138 - props, 139 - rep, 140 - areYouSure, 141 - setAreYouSure, 142 - isLocked, 143 - }: Args) { 133 + async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 144 134 // if this is a textBlock, let the textBlock/keymap handle the backspace 145 - if (isLocked) return; 146 135 // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 147 136 let el = e.target as HTMLElement; 148 137 if (
-103
components/Toolbar/LockBlockButton.tsx
··· 1 - import { useUIState } from "src/useUIState"; 2 - import { ToolbarButton } from "."; 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - 5 - import { focusBlock } from "src/utils/focusBlock"; 6 - import { Props } from "components/Icons/Props"; 7 - 8 - export function LockBlockButton() { 9 - let focusedBlock = useUIState((s) => s.focusedEntity); 10 - let selectedBlocks = useUIState((s) => s.selectedBlocks); 11 - let type = useEntity(focusedBlock?.entityID || null, "block/type"); 12 - let locked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); 13 - let { rep } = useReplicache(); 14 - if (focusedBlock?.entityType !== "block") return; 15 - return ( 16 - <ToolbarButton 17 - disabled={false} 18 - onClick={async () => { 19 - if (!locked?.data.value) { 20 - await rep?.mutate.assertFact({ 21 - entity: focusedBlock.entityID, 22 - attribute: "block/is-locked", 23 - data: { value: true, type: "boolean" }, 24 - }); 25 - if (selectedBlocks.length > 1) { 26 - for (let block of selectedBlocks) { 27 - await rep?.mutate.assertFact({ 28 - attribute: "block/is-locked", 29 - entity: block.value, 30 - data: { value: true, type: "boolean" }, 31 - }); 32 - } 33 - } 34 - } else { 35 - await rep?.mutate.retractFact({ factID: locked.id }); 36 - if (selectedBlocks.length > 1) { 37 - for (let block of selectedBlocks) { 38 - await rep?.mutate.retractAttribute({ 39 - attribute: "block/is-locked", 40 - entity: block.value, 41 - }); 42 - } 43 - } else { 44 - type && 45 - focusBlock( 46 - { 47 - type: type.data.value, 48 - parent: focusedBlock.parent, 49 - value: focusedBlock.entityID, 50 - }, 51 - { type: "end" }, 52 - ); 53 - } 54 - } 55 - }} 56 - tooltipContent={ 57 - <span>{!locked?.data.value ? "Lock Editing" : " Unlock to Edit"}</span> 58 - } 59 - > 60 - {!locked?.data.value ? <LockSmall /> : <UnlockSmall />} 61 - </ToolbarButton> 62 - ); 63 - } 64 - 65 - const LockSmall = (props: Props) => { 66 - return ( 67 - <svg 68 - width="24" 69 - height="24" 70 - viewBox="0 0 24 24" 71 - fill="none" 72 - xmlns="http://www.w3.org/2000/svg" 73 - {...props} 74 - > 75 - <path 76 - fillRule="evenodd" 77 - clipRule="evenodd" 78 - 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" 79 - fill="currentColor" 80 - /> 81 - </svg> 82 - ); 83 - }; 84 - 85 - const UnlockSmall = (props: Props) => { 86 - return ( 87 - <svg 88 - width="24" 89 - height="24" 90 - viewBox="0 0 24 24" 91 - fill="none" 92 - xmlns="http://www.w3.org/2000/svg" 93 - {...props} 94 - > 95 - <path 96 - fillRule="evenodd" 97 - clipRule="evenodd" 98 - 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" 99 - fill="currentColor" 100 - /> 101 - </svg> 102 - ); 103 - };
+3 -5
components/Toolbar/MultiSelectToolbar.tsx
··· 3 3 import { ToolbarButton } from "./index"; 4 4 import { copySelection } from "src/utils/copySelection"; 5 5 import { useSmoker, useToaster } from "components/Toast"; 6 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 7 - import { Replicache } from "replicache"; 8 - import { LockBlockButton } from "./LockBlockButton"; 6 + 9 7 import { Props } from "components/Icons/Props"; 10 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 9 import { getSortedSelection } from "components/SelectionManager/selectionState"; 12 10 import { deleteBlock } from "src/utils/deleteBlock"; 13 - import { ShortcutKey } from "components/Layout"; 11 + import { Separator, ShortcutKey } from "components/Layout"; 14 12 15 13 export const MultiselectToolbar = (props: { 16 14 setToolbarState: (state: "multiselect" | "text-alignment") => void; ··· 69 67 <CopySmall /> 70 68 </ToolbarButton> 71 69 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 72 - <LockBlockButton /> 70 + <Separator classname="h-6!" /> 73 71 </div> 74 72 </div> 75 73 );
-3
components/Toolbar/TextToolbar.tsx
··· 8 8 import { ToolbarTypes } from "."; 9 9 import { schema } from "components/Blocks/TextBlock/schema"; 10 10 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 11 - import { LockBlockButton } from "./LockBlockButton"; 12 11 import { Props } from "components/Icons/Props"; 13 12 import { isMac } from "src/utils/isDevice"; 14 13 ··· 81 80 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 81 <ListButton setToolbarState={props.setToolbarState} /> 83 82 <Separator classname="h-6!" /> 84 - 85 - <LockBlockButton /> 86 83 </> 87 84 ); 88 85 };
+7 -4
components/Toolbar/index.tsx
··· 38 38 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 39 39 40 40 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 41 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 41 42 42 43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 43 44 let setLastUsedHighlight = (color: "1" | "2" | "3") => ··· 65 66 props.blockType === "blockquote"; 66 67 67 68 useEffect(() => { 69 + if (selectedBlocks.length > 1) { 70 + setToolbarState("multiselect"); 71 + return; 72 + } 68 73 if (isTextBlock) { 69 74 setToolbarState("default"); 70 75 } ··· 74 79 if (props.blockType === "button" || props.blockType === "datetime") { 75 80 setToolbarState("text-alignment"); 76 81 } else null; 77 - }, [props.blockType]); 82 + }, [props.blockType, selectedBlocks]); 78 83 79 84 let isMobile = useIsMobile(); 80 85 return ( ··· 165 170 hiddenOnCanvas?: boolean; 166 171 }) => { 167 172 let focusedEntity = useUIState((s) => s.focusedEntity); 168 - let isLocked = useEntity(focusedEntity?.entityID || null, "block/is-locked"); 169 - let isDisabled = 170 - props.disabled === undefined ? !!isLocked?.data.value : props.disabled; 173 + let isDisabled = props.disabled; 171 174 172 175 let focusedEntityType = useEntity( 173 176 focusedEntity?.entityType === "page"
-5
src/replicache/mutations.ts
··· 308 308 { blockEntity: string } | { blockEntity: string }[] 309 309 > = async (args, ctx) => { 310 310 for (let block of [args].flat()) { 311 - let [isLocked] = await ctx.scanIndex.eav( 312 - block.blockEntity, 313 - "block/is-locked", 314 - ); 315 - if (isLocked?.data.value) continue; 316 311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 317 312 await ctx.runOnServer(async ({ supabase }) => { 318 313 if (image) {