From 640fa69110e3fe71279e9cfd335a7626b451c06a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 14 Nov 2024 14:25:42 -0500 Subject: [PATCH] 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 --- app/globals.css | 2 + components/Blocks/Block.tsx | 75 +++++++++++++---- components/Blocks/EmbedBlock.tsx | 6 +- components/Blocks/ExternalLinkBlock.tsx | 4 +- components/Blocks/ImageBlock.tsx | 5 +- components/Blocks/TextBlock/index.tsx | 10 ++- components/Blocks/index.tsx | 6 +- components/Blocks/useBlockKeyboardHandlers.ts | 26 +++++- components/Canvas.tsx | 6 +- components/Icons.tsx | 80 +++++++++++++++++++ components/Toolbar/BlockToolbar.tsx | 11 ++- components/Toolbar/LockBlockButton.tsx | 62 ++++++++++++++ components/Toolbar/MultiSelectToolbar.tsx | 3 + components/Toolbar/TextToolbar.tsx | 21 +++-- components/Toolbar/index.tsx | 19 +++-- public/hatchPattern.svg | 8 ++ src/replicache/attributes.ts | 4 + src/replicache/mutations.ts | 5 ++ src/utils/focusBlock.ts | 12 +-- 19 files changed, 312 insertions(+), 53 deletions(-) create mode 100644 components/Toolbar/LockBlockButton.tsx create mode 100644 public/hatchPattern.svg diff --git a/app/globals.css b/app/globals.css index a4555b52..147f2c3b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -24,7 +24,9 @@ --gripperSVG: url("/gripperPattern.svg"); --gripperSVG2: url("/gripperPattern2.svg"); + --hatchSVG: url("/hatchPattern.svg"); } + @media (max-width: 640px) { :root { --list-marker-width: 20px; diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 197634c2..801e223f 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -15,9 +15,16 @@ import { ExternalLinkBlock } from "./ExternalLinkBlock"; import { EmbedBlock } from "./EmbedBlock"; import { MailboxBlock } from "./MailboxBlock"; import { HeadingBlock } from "./HeadingBlock"; -import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; +import { + CheckboxChecked, + CheckboxEmpty, + LockTiny, + UnlockSmall, + UnlockTiny, +} from "components/Icons"; import { AreYouSure } from "./DeleteBlock"; import { useEntitySetContext } from "components/EntitySetProvider"; +import { Media } from "components/Media"; import { useIsMobile } from "src/hooks/isMobile"; import { DateTimeBlock } from "./DateTimeBlock"; @@ -115,10 +122,10 @@ export const BaseBlock = ( setAreYouSure?: (value: boolean) => void; }, ) => { - // BaseBlock renders the actual block content + // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers let BlockTypeComponent = BlockTypeComponents[props.type]; return ( -
+
{props.listData && } {props.areYouSure ? ( { + let { rep } = useReplicache(); + let isMobile = useIsMobile(); + let first = props.previousBlock === null; let isMultiselected = useUIState( @@ -159,6 +169,11 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.length > 1, ); + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let isLocked = useEntity(props.value, "block/is-locked"); + let nextBlockSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), ); @@ -166,19 +181,49 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), ); - if (isMultiselected) - // not sure what multiselected and selected is doing (?) + if (isMultiselected || (isLocked?.data.value && isSelected)) + // not sure what multiselected and selected classes are doing (?) + // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected + return ( -
+ <> +
+ {isLocked?.data.value && ( +
+ +
+ )} + ); }; diff --git a/components/Blocks/EmbedBlock.tsx b/components/Blocks/EmbedBlock.tsx index 01b15063..be4ef70d 100644 --- a/components/Blocks/EmbedBlock.tsx +++ b/components/Blocks/EmbedBlock.tsx @@ -22,6 +22,7 @@ export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); + useEffect(() => { if (props.preview) return; let input = document.getElementById(elementId.block(props.entityID).input); @@ -90,6 +91,8 @@ const BlockLinkInput = (props: BlockProps) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; + let entity_set = useEntitySetContext(); let [linkValue, setLinkValue] = useState(""); let { rep } = useReplicache(); @@ -139,6 +142,7 @@ const BlockLinkInput = (props: BlockProps) => { className="w-full grow border-none outline-none bg-transparent " placeholder="www.example.com" value={linkValue} + disabled={isLocked} onChange={(e) => setLinkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Backspace" && linkValue === "") { @@ -162,7 +166,7 @@ const BlockLinkInput = (props: BlockProps) => { />
diff --git a/components/Toolbar/TextToolbar.tsx b/components/Toolbar/TextToolbar.tsx index a9e51228..e9caaf1d 100644 --- a/components/Toolbar/TextToolbar.tsx +++ b/components/Toolbar/TextToolbar.tsx @@ -10,6 +10,7 @@ import { HighlightButton } from "./HighlightToolbar"; import { ToolbarTypes } from "."; import { schema } from "components/Blocks/TextBlock/schema"; import { TextAlignmentButton } from "./TextAlignmentToolbar"; +import { LockBlockButton } from "./LockBlockButton"; export const TextToolbar = (props: { lastUsedHighlight: string; @@ -31,13 +32,15 @@ export const TextToolbar = (props: { icon={} /> -
Italic
-
- {metaKey()} +{" "} - I + tooltipContent={ +
+
Italic
+
+ {metaKey()} +{" "} + I +
-
+ } mark={schema.marks.em} icon={} /> @@ -72,12 +75,14 @@ export const TextToolbar = (props: { setToolbarState={props.setToolbarState} /> + + - - + + ); }; diff --git a/components/Toolbar/index.tsx b/components/Toolbar/index.tsx index 9f80ea1f..16a40e04 100644 --- a/components/Toolbar/index.tsx +++ b/components/Toolbar/index.tsx @@ -82,8 +82,8 @@ export const Toolbar = (props: { pageID: string; blockID: string }) => { return ( -
-
+
+
{toolbarState === "default" ? ( { + let focusedBlock = useUIState((s) => s.focusedEntity); + let isLocked = useEntity(focusedBlock?.entityID || null, "block/is-locked"); + let isDisabled = + props.disabled === undefined ? !!isLocked?.data.value : props.disabled; + return ( { e.preventDefault(); props.onClick && props.onClick(e); }} - disabled={props.disabled} + disabled={isDisabled} content={props.tooltipContent} className={` - flex items-center rounded-md border border-transparent hover:border-border active:bg-border-light active:text-primary + flex items-center rounded-md border border-transparent ${props.className} ${ - props.active + props.active && !isDisabled ? "bg-border-light text-primary" - : props.disabled + : isDisabled ? "text-border cursor-not-allowed" - : "text-secondary hover:text-primary" + : "text-secondary hover:text-primary hover:border-border active:bg-border-light active:text-primary" } `} > diff --git a/public/hatchPattern.svg b/public/hatchPattern.svg new file mode 100644 index 00000000..4357016f --- /dev/null +++ b/public/hatchPattern.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index 32a2b80b..243910e7 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -44,6 +44,10 @@ const BlockAttributes = { type: "boolean", cardinality: "one", }, + "block/is-locked": { + type: "boolean", + cardinality: "one", + }, "block/check-list": { type: "boolean", cardinality: "one", diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index f8c2ea76..be3dadfe 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -289,6 +289,11 @@ const removeBlock: Mutation< { blockEntity: string } | { blockEntity: string }[] > = async (args, ctx) => { for (let block of [args].flat()) { + let [isLocked] = await ctx.scanIndex.eav( + block.blockEntity, + "block/is-locked", + ); + if (isLocked?.data.value) continue; let images = await ctx.scanIndex.eav(block.blockEntity, "block/image"); ctx.runOnServer(async ({ supabase }) => { for (let image of images) { diff --git a/src/utils/focusBlock.ts b/src/utils/focusBlock.ts index 8915ebf2..0cf010bd 100644 --- a/src/utils/focusBlock.ts +++ b/src/utils/focusBlock.ts @@ -8,13 +8,13 @@ export function focusBlock( block: Pick, position: Position, ) { + useUIState.getState().setSelectedBlock(block); + useUIState.getState().setFocusedBlock({ + entityType: "block", + entityID: block.value, + parent: block.parent, + }); if (block.type !== "text" && block.type !== "heading") { - useUIState.getState().setSelectedBlock(block); - useUIState.getState().setFocusedBlock({ - entityType: "block", - entityID: block.value, - parent: block.parent, - }); return true; } let nextBlockID = block.value; -- 2.54.0