a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feature/button block (#119)

* WIP add new button block type, that accepts text and url attributes

* style and fix bugs in the button block, streamlined delete block logic in input affiliated blocks, and fixed bug in which BlockCommandBar was being called twice

* autofocused the button block text input and cleaned up some stray console logs

---------

Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>

authored by

celine
Brendan Schlagel
and committed by
GitHub
3f6e00d3 54431fb5

+320 -58
-1
app/home/LeafletOptions.tsx
··· 75 75 )} 76 76 <MenuItem 77 77 onSelect={async () => { 78 - console.log(props.loggedIn); 79 78 if (props.loggedIn) { 80 79 mutateIdentity( 81 80 (s) => {
+2
components/Blocks/Block.tsx
··· 22 22 import { DateTimeBlock } from "./DateTimeBlock"; 23 23 import { RSVPBlock } from "./RSVPBlock"; 24 24 import { elementId } from "src/utils/elementId"; 25 + import { ButtonBlock } from "./ButtonBlock"; 25 26 26 27 export type Block = { 27 28 factID: string; ··· 156 157 mailbox: MailboxBlock, 157 158 datetime: DateTimeBlock, 158 159 rsvp: RSVPBlock, 160 + button: ButtonBlock, 159 161 }; 160 162 161 163 export const BlockMultiselectIndicator = (props: BlockProps) => {
+1 -3
components/Blocks/BlockCommandBar.tsx
··· 168 168 <button 169 169 className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`} 170 170 onMouseOver={() => { 171 - isHighlighted 172 - ? props.setHighlighted(undefined) 173 - : props.setHighlighted(props.name); 171 + props.setHighlighted(props.name); 174 172 }} 175 173 onMouseDown={(e) => { 176 174 e.preventDefault();
+9
components/Blocks/BlockCommands.tsx
··· 11 11 ParagraphSmall, 12 12 LinkSmall, 13 13 BlockEmbedSmall, 14 + BlockButtonSmall, 14 15 BlockCalendarSmall, 15 16 RSVPSmall, 16 17 } from "components/Icons"; ··· 184 185 let el = document.getElementById(elementId.block(entity).input); 185 186 el?.focus(); 186 187 }, 100); 188 + }, 189 + }, 190 + { 191 + name: "Button", 192 + icon: <BlockButtonSmall />, 193 + type: "block", 194 + onSelect: async (rep, props) => { 195 + createBlockWithType(rep, props, "button"); 187 196 }, 188 197 }, 189 198 {
+214
components/Blocks/ButtonBlock.tsx
··· 1 + import { useEntitySetContext } from "components/EntitySetProvider"; 2 + import { generateKeyBetween } from "fractional-indexing"; 3 + import { useCallback, useEffect, useState } from "react"; 4 + import { useEntity, useReplicache } from "src/replicache"; 5 + import { useUIState } from "src/useUIState"; 6 + import { BlockProps } from "./Block"; 7 + import { v7 } from "uuid"; 8 + import { useSmoker } from "components/Toast"; 9 + import { 10 + BlockEmbedSmall, 11 + LinkSmall, 12 + CheckTiny, 13 + BlockButtonSmall, 14 + } from "components/Icons"; 15 + import { Separator } from "components/Layout"; 16 + import { Input } from "components/Input"; 17 + import { isUrl } from "src/utils/isURL"; 18 + import { deleteBlock } from "./DeleteBlock"; 19 + import { ButtonPrimary } from "components/Buttons"; 20 + 21 + export const ButtonBlock = (props: BlockProps & { preview?: boolean }) => { 22 + let { permissions } = useEntitySetContext(); 23 + 24 + let text = useEntity(props.entityID, "button/text"); 25 + let url = useEntity(props.entityID, "button/url"); 26 + 27 + let isSelected = useUIState((s) => 28 + s.selectedBlocks.find((b) => b.value === props.entityID), 29 + ); 30 + 31 + if (!url) { 32 + if (!permissions.write) return null; 33 + return <ButtonBlockSettings {...props} />; 34 + } 35 + 36 + return ( 37 + <form 38 + action={url?.data.value} 39 + target="_blank" 40 + className={`mx-auto hover:outline-accent-contrast !rounded-md ${isSelected ? "block-border-selected !border-0" : "block-border !border-transparent !border-0"}`} 41 + > 42 + <ButtonPrimary type="submit">{text?.data.value}</ButtonPrimary> 43 + </form> 44 + ); 45 + }; 46 + 47 + const ButtonBlockSettings = (props: BlockProps) => { 48 + let { rep } = useReplicache(); 49 + let smoker = useSmoker(); 50 + let entity_set = useEntitySetContext(); 51 + 52 + let isSelected = useUIState((s) => 53 + s.selectedBlocks.find((b) => b.value === props.entityID), 54 + ); 55 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 56 + 57 + let [textValue, setTextValue] = useState(""); 58 + let [urlValue, setUrlValue] = useState(""); 59 + let text = textValue; 60 + let url = urlValue; 61 + 62 + let submit = async () => { 63 + let entity = props.entityID; 64 + if (!entity) { 65 + entity = v7(); 66 + await rep?.mutate.addBlock({ 67 + permission_set: entity_set.set, 68 + factID: v7(), 69 + parent: props.parent, 70 + type: "card", 71 + position: generateKeyBetween(props.position, props.nextPosition), 72 + newEntityID: entity, 73 + }); 74 + } 75 + 76 + //TODO: exceptions for mailto and tel as well? 77 + if (!urlValue.startsWith("http")) url = `https://${urlValue}`; 78 + 79 + // these mutations = simpler subset of addLinkBlock 80 + if (!rep) return; 81 + await rep.mutate.assertFact({ 82 + entity: entity, 83 + attribute: "block/type", 84 + data: { type: "block-type-union", value: "button" }, 85 + }); 86 + await rep?.mutate.assertFact({ 87 + entity: entity, 88 + attribute: "button/text", 89 + data: { 90 + type: "string", 91 + value: text, 92 + }, 93 + }); 94 + await rep?.mutate.assertFact({ 95 + entity: entity, 96 + attribute: "button/url", 97 + data: { 98 + type: "string", 99 + value: url, 100 + }, 101 + }); 102 + }; 103 + 104 + return ( 105 + <div className="buttonBlockSettingsWrapper flex flex-col gap-2 w-full"> 106 + <ButtonPrimary className="mx-auto"> 107 + {text !== "" ? text : "Button"} 108 + </ButtonPrimary> 109 + 110 + <form 111 + className={` 112 + buttonBlockSettingsBorder 113 + w-full bg-bg-page 114 + text-tertiary hover:text-accent-contrast hover:cursor-pointer hover:p-0 115 + flex flex-col gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 116 + ${isSelected ? "border-2 border-tertiary p-0" : "border border-border p-[1px]"} 117 + `} 118 + onSubmit={(e) => { 119 + e.preventDefault(); 120 + let rect = document 121 + .getElementById("button-block-settings") 122 + ?.getBoundingClientRect(); 123 + if (!textValue) { 124 + smoker({ 125 + error: true, 126 + text: "missing button text!", 127 + position: { 128 + y: rect ? rect.top : 0, 129 + x: rect ? rect.left + 12 : 0, 130 + }, 131 + }); 132 + return; 133 + } 134 + if (!urlValue) { 135 + smoker({ 136 + error: true, 137 + text: "missing url!", 138 + position: { 139 + y: rect ? rect.top : 0, 140 + x: rect ? rect.left + 12 : 0, 141 + }, 142 + }); 143 + return; 144 + } 145 + if (!isUrl(urlValue)) { 146 + smoker({ 147 + error: true, 148 + text: "invalid url!", 149 + position: { 150 + y: rect ? rect.top : 0, 151 + x: rect ? rect.left + 12 : 0, 152 + }, 153 + }); 154 + return; 155 + } 156 + submit(); 157 + }} 158 + > 159 + <div className="buttonBlockSettingsContent w-full flex flex-col sm:flex-row gap-2 text-secondary px-2 py-3 sm:pb-3 pb-1"> 160 + <div className="buttonBlockSettingsTitleInput flex gap-2 w-full sm:w-52"> 161 + <BlockButtonSmall 162 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 163 + /> 164 + <Separator /> 165 + <Input 166 + type="text" 167 + autoFocus 168 + className="w-full grow border-none outline-none bg-transparent" 169 + placeholder="button text" 170 + value={textValue} 171 + disabled={isLocked} 172 + onChange={(e) => setTextValue(e.target.value)} 173 + onKeyDown={(e) => { 174 + if ( 175 + e.key === "Backspace" && 176 + !e.currentTarget.value && 177 + urlValue !== "" 178 + ) 179 + e.preventDefault(); 180 + }} 181 + /> 182 + </div> 183 + <div className="buttonBlockSettingsLinkInput grow flex gap-2 w-full"> 184 + <LinkSmall 185 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 186 + /> 187 + <Separator /> 188 + <Input 189 + type="url" 190 + id="button-block-url-input" 191 + className="w-full grow border-none outline-none bg-transparent" 192 + placeholder="www.example.com" 193 + value={urlValue} 194 + disabled={isLocked} 195 + onChange={(e) => setUrlValue(e.target.value)} 196 + onKeyDown={(e) => { 197 + if (e.key === "Backspace" && !e.currentTarget.value) 198 + e.preventDefault(); 199 + }} 200 + /> 201 + </div> 202 + <button 203 + id="button-block-settings" 204 + type="submit" 205 + 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"}`} 206 + > 207 + <div className="sm:hidden block">Save</div> 208 + <CheckTiny /> 209 + </button> 210 + </div> 211 + </form> 212 + </div> 213 + ); 214 + };
+53 -45
components/Blocks/EmbedBlock.tsx
··· 166 166 let smoker = useSmoker(); 167 167 168 168 return ( 169 - <div> 169 + <form 170 + onSubmit={(e) => { 171 + e.preventDefault(); 172 + let rect = document 173 + .getElementById("embed-block-submit") 174 + ?.getBoundingClientRect(); 175 + if (!linkValue || linkValue === "") { 176 + smoker({ 177 + error: true, 178 + text: "no url!", 179 + position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 }, 180 + }); 181 + return; 182 + } 183 + if (!isUrl(linkValue)) { 184 + smoker({ 185 + error: true, 186 + text: "invalid url!", 187 + position: { 188 + x: rect ? rect.left + 12 : 0, 189 + y: rect ? rect.top : 0, 190 + }, 191 + }); 192 + return; 193 + } 194 + submit(); 195 + }} 196 + > 170 197 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 171 198 <BlockEmbedSmall 172 199 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} ··· 179 206 value={linkValue} 180 207 disabled={isLocked} 181 208 onChange={(e) => setLinkValue(e.target.value)} 182 - onKeyDown={(e) => { 183 - if (e.key === "Backspace" && linkValue === "") { 184 - rep && deleteBlock([props.entityID].flat(), rep); 209 + /> 210 + <button 211 + type="submit" 212 + id="embed-block-submit" 213 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 214 + onMouseDown={(e) => { 215 + e.preventDefault(); 216 + if (!linkValue || linkValue === "") { 217 + smoker({ 218 + error: true, 219 + text: "no url!", 220 + position: { x: e.clientX + 12, y: e.clientY }, 221 + }); 185 222 return; 186 223 } 187 - if (e.key === "Enter") { 188 - if (!linkValue) return; 189 - if (!isUrl(linkValue)) { 190 - let rect = e.currentTarget.getBoundingClientRect(); 191 - smoker({ 192 - error: true, 193 - text: "invalid url!", 194 - position: { x: rect.left, y: rect.top - 8 }, 195 - }); 196 - return; 197 - } 198 - submit(); 224 + if (!isUrl(linkValue)) { 225 + smoker({ 226 + error: true, 227 + text: "invalid url!", 228 + position: { x: e.clientX + 12, y: e.clientY }, 229 + }); 230 + return; 199 231 } 232 + submit(); 200 233 }} 201 - /> 202 - <div className="flex items-center gap-3 "> 203 - <button 204 - className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 205 - onMouseDown={(e) => { 206 - e.preventDefault(); 207 - if (!linkValue || linkValue === "") { 208 - smoker({ 209 - error: true, 210 - text: "no url!", 211 - position: { x: e.clientX, y: e.clientY }, 212 - }); 213 - return; 214 - } 215 - if (!isUrl(linkValue)) { 216 - smoker({ 217 - error: true, 218 - text: "invalid url!", 219 - position: { x: e.clientX, y: e.clientY }, 220 - }); 221 - return; 222 - } 223 - submit(); 224 - }} 225 - > 226 - <CheckTiny /> 227 - </button> 228 - </div> 234 + > 235 + <CheckTiny /> 236 + </button> 229 237 </div> 230 - </div> 238 + </form> 231 239 ); 232 240 };
+4
components/Blocks/RSVPBlock/ContactDetailsForm.tsx
··· 123 123 placeholder="..." 124 124 className=" bg-transparent disabled:text-tertiary w-full appearance-none focus:outline-0" 125 125 value={name} 126 + onKeyDown={(e) => { 127 + if (e.key === "Backspace" && !e.currentTarget.value) 128 + e.preventDefault(); 129 + }} 126 130 onChange={(e) => setName(e.target.value)} 127 131 /> 128 132 </label>
+1 -6
components/Blocks/TextBlock/index.tsx
··· 473 473 </TooltipButton> 474 474 </div> 475 475 ) : null} 476 - {editorState.doc.textContent.startsWith("/") && selected && ( 477 - <BlockCommandBar 478 - props={props} 479 - searchValue={editorState.doc.textContent.slice(1)} 480 - /> 481 - )} 476 + 482 477 {editorState.doc.textContent.startsWith("/") && selected && ( 483 478 <BlockCommandBar 484 479 props={props}
+3 -2
components/Blocks/useBlockKeyboardHandlers.ts
··· 126 126 // if this is a textBlock, let the textBlock/keymap handle the backspace 127 127 if (isLocked) return; 128 128 if (isTextBlock[props.type]) return; 129 + // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) 129 130 let el = e.target as HTMLElement; 130 131 if ( 131 132 el.tagName === "LABEL" || ··· 151 152 return; 152 153 } 153 154 // ... and areYouSure state is true, 154 - // and the user is not in an input or textarea, 155 - // if there is a page to close, close it and remove the block 155 + // and the user is not in an input or textarea, remove it 156 + // if there is a page to close, close it 156 157 if (areYouSure) { 157 158 e.preventDefault(); 158 159 if (debounced) {
+19
components/Icons.tsx
··· 124 124 ); 125 125 }; 126 126 127 + export const BlockButtonSmall = (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.53207 1.97182C7.53207 1.5576 7.19628 1.22182 6.78207 1.22182C6.36785 1.22182 6.03207 1.5576 6.03207 1.97182V4.05128C6.03207 4.46549 6.36785 4.80128 6.78207 4.80128C7.19628 4.80128 7.53207 4.46549 7.53207 4.05128V1.97182ZM3.62975 2.21817C3.37649 1.8904 2.90548 1.82999 2.5777 2.08325C2.24993 2.3365 2.18953 2.80752 2.44278 3.13529L4.01521 5.17036C4.26846 5.49813 4.73948 5.55854 5.06725 5.30528C5.39502 5.05202 5.45543 4.58101 5.20217 4.25324L3.62975 2.21817ZM10.558 3.78971C10.7922 3.4481 10.7052 2.98127 10.3636 2.74702C10.0219 2.51277 9.55512 2.59981 9.32087 2.94142L8.39772 4.28767C8.16347 4.62929 8.25051 5.09612 8.59212 5.33037C8.93373 5.56462 9.40057 5.47758 9.63482 5.13597L10.558 3.78971ZM1.27137 5.25744C0.865303 5.17568 0.469839 5.43857 0.388073 5.84463C0.306306 6.2507 0.569201 6.64616 0.975264 6.72793L3.27446 7.1909C3.68053 7.27267 4.07599 7.00977 4.15776 6.60371C4.23952 6.19765 3.97663 5.80218 3.57056 5.72042L1.27137 5.25744ZM3.84382 9.46008C4.23975 9.33839 4.46208 8.91878 4.34039 8.52284C4.2187 8.1269 3.79909 7.90458 3.40315 8.02627L1.81228 8.5152C1.41635 8.63689 1.19402 9.0565 1.31571 9.45244C1.4374 9.84837 1.85701 10.0707 2.25295 9.94901L3.84382 9.46008ZM7.03725 6.81012C6.88041 6.76897 6.69114 6.77767 6.47065 6.94802C6.28652 7.09028 6.24349 7.25009 6.2559 7.42889C6.27066 7.64151 6.36831 7.83561 6.41379 7.89654L6.42359 7.91004L11.1362 14.5892C11.2707 14.7799 11.2878 15.0296 11.1806 15.2368C11.0733 15.444 10.8596 15.5743 10.6263 15.5745C10.0976 15.5752 9.27053 15.5217 8.69318 15.4844L8.69309 15.4844C8.44321 15.4682 8.24012 15.4551 8.12826 15.4507C7.80371 15.4379 7.45335 15.6385 7.38185 16.0112C7.33227 16.2698 7.37677 16.4188 7.42966 16.5081C7.4861 16.6035 7.59584 16.704 7.79728 16.783C7.88252 16.8164 8.22336 16.9137 8.78389 17.0619C9.32178 17.2041 10.0151 17.3815 10.7627 17.5716L11.4606 17.749L11.4613 17.7491C12.6176 18.0428 13.8237 18.349 14.7222 18.5886L19.9243 14.6724C19.9209 14.6279 19.9144 14.5745 19.9034 14.5127C19.8598 14.2687 19.7582 13.9611 19.5955 13.6714C19.4685 13.4455 19.3161 13.1714 19.1477 12.8687L19.1476 12.8684L19.1473 12.868L19.1472 12.8678L19.1468 12.867C18.8486 12.3309 18.5006 11.7054 18.1565 11.0992C17.6106 10.1376 17.1103 9.28974 16.8597 8.95397C16.6926 8.73002 16.2319 8.48077 15.6815 8.55951L16.2659 9.14063C16.5106 9.38403 16.5117 9.77975 16.2683 10.0245C16.025 10.2693 15.6292 10.2704 15.3845 10.027L14.1471 8.79644C14.1423 8.7917 14.1376 8.78689 14.133 8.782C14.0429 8.68641 13.7456 8.55949 13.3474 8.58823C13.2195 8.59747 13.1124 8.62004 13.0242 8.65026L14.3817 10.0542C14.6216 10.3024 14.6149 10.6981 14.3668 10.938C14.1186 11.1779 13.7229 11.1713 13.483 10.9231L11.6861 9.06455C11.4948 8.87064 11.2357 8.77488 10.9918 8.79404C10.8564 8.80468 10.7078 8.85211 10.5641 8.96174L12.5193 11.3775C12.7365 11.6458 12.695 12.0394 12.4267 12.2565C12.1584 12.4737 11.7648 12.4322 11.5477 12.1639L9.26591 9.34461L9.26445 9.34279L7.48373 7.12574C7.47642 7.11664 7.46936 7.10733 7.46257 7.09783C7.38144 6.98434 7.22126 6.8584 7.03725 6.81012ZM11.156 18.9613C12.1476 19.2131 13.1677 19.4721 13.9985 19.6902C13.9616 19.9621 13.9927 20.2968 14.1943 20.567L15.1543 21.8533C15.4795 22.289 15.8967 22.5237 16.368 22.4786C16.747 22.4423 17.0375 22.2248 17.1542 22.1374L17.1653 22.1291C18.0981 21.4329 19.0298 20.7353 19.9615 20.0377L19.9638 20.0359C20.8948 19.3389 21.8257 18.6419 22.7577 17.9463C23.0316 17.7419 23.2256 17.4441 23.2533 17.0807C23.2799 16.7324 23.1468 16.4164 22.9591 16.1649L21.9991 14.8786C21.7829 14.5889 21.4706 14.3692 21.133 14.2882C21.065 13.9107 20.9172 13.472 20.6853 13.0592C20.5634 12.8423 20.4135 12.5727 20.2465 12.2726L20.246 12.2717C19.9452 11.731 19.5894 11.0913 19.2435 10.4821C18.7126 9.54681 18.1695 8.61909 17.8615 8.20633C17.2927 7.44422 15.9261 6.93571 14.6184 7.60472C14.2011 7.38147 13.6924 7.31008 13.2574 7.34148C12.8614 7.37007 12.3952 7.49539 12.0292 7.78282C11.6844 7.60323 11.2922 7.51659 10.8939 7.54788C10.4962 7.57913 10.1124 7.7263 9.77985 7.98835L8.46804 6.35511C8.22863 6.02801 7.83363 5.72676 7.35447 5.60104C6.84044 5.46618 6.24812 5.54034 5.70642 5.95885C5.12836 6.40546 4.97358 7.00672 5.0089 7.51546C5.04151 7.98521 5.23324 8.40116 5.4063 8.63646L9.38501 14.2755C9.21037 14.2648 9.03901 14.2536 8.87896 14.2432L8.8788 14.2432L8.87855 14.2431C8.59391 14.2245 8.34511 14.2083 8.17747 14.2017C7.41871 14.1718 6.37369 14.6316 6.15423 15.7758C6.05834 16.2757 6.11804 16.7461 6.35389 17.1447C6.58617 17.5373 6.94968 17.7932 7.34083 17.9467C7.51114 18.0135 7.94268 18.1324 8.46435 18.2703C9.00865 18.4143 9.7073 18.593 10.4546 18.7831L11.1558 18.9612L11.156 18.9613ZM15.2354 19.872C15.2382 19.8391 15.2475 19.8049 15.263 19.7741L20.8537 15.5655C20.9132 15.5586 20.9671 15.5857 20.9974 15.6262L21.9141 16.8555C21.961 16.9375 21.9559 16.9933 21.8789 17.0508C20.4678 18.1049 19.1219 19.1093 17.7418 20.1391L16.4177 21.1273C16.2854 21.226 16.2588 21.2432 16.1561 21.1056L15.2354 19.872Z" 141 + fill="currentColor" 142 + /> 143 + </svg> 144 + ); 145 + }; 127 146 export const BlockLinkSmall = (props: Props) => { 128 147 return ( 129 148 <svg
+14 -1
src/replicache/attributes.ts
··· 123 123 }, 124 124 } as const; 125 125 126 + const ButtonBlockAttributes = { 127 + "button/text": { 128 + type: "string", 129 + cardinality: "one", 130 + }, 131 + "button/url": { 132 + type: "string", 133 + cardinality: "one", 134 + }, 135 + } as const; 136 + 126 137 export const ThemeAttributes = { 127 138 "theme/page-leaflet-watermark": { 128 139 type: "boolean", ··· 190 201 ...ThemeAttributes, 191 202 ...MailboxAttributes, 192 203 ...EmbedBlockAttributes, 204 + ...ButtonBlockAttributes, 193 205 }; 194 206 type Attribute = typeof Attributes; 195 207 export type Data<A extends keyof typeof Attributes> = { ··· 248 260 | "heading" 249 261 | "link" 250 262 | "mailbox" 251 - | "embed"; 263 + | "embed" 264 + | "button"; 252 265 }; 253 266 "canvas-pattern-union": { 254 267 type: "canvas-pattern-union";