a tool for shared writing and social publishing
at feature/recommend 251 lines 9.0 kB view raw
1import { 2 Header1Small, 3 Header2Small, 4 Header3Small, 5} from "components/Icons/BlockTextSmall"; 6import { Props } from "components/Icons/Props"; 7import { ShortcutKey, Separator } from "components/Layout"; 8import { ToolbarButton } from "components/Toolbar"; 9import { TextSelection } from "prosemirror-state"; 10import { useCallback } from "react"; 11import { useEntity, useReplicache } from "src/replicache"; 12import { useEditorStates } from "src/state/useEditorState"; 13import { useUIState } from "src/useUIState"; 14 15export const TextBlockTypeToolbar = (props: { 16 onClose: () => void; 17 className?: string; 18}) => { 19 let focusedBlock = useUIState((s) => s.focusedEntity); 20 let blockType = useEntity(focusedBlock?.entityID || null, "block/type"); 21 let headingLevel = useEntity( 22 focusedBlock?.entityID || null, 23 "block/heading-level", 24 ); 25 26 let textSize = useEntity(focusedBlock?.entityID || null, "block/text-size"); 27 let { rep } = useReplicache(); 28 29 let setLevel = useCallback( 30 async (level: number) => { 31 if (!focusedBlock) return; 32 let entityID = focusedBlock.entityID; 33 if ( 34 blockType?.data.value !== "text" && 35 blockType?.data.value !== "heading" 36 ) { 37 return; 38 } 39 await rep?.mutate.assertFact({ 40 entity: entityID, 41 attribute: "block/heading-level", 42 data: { type: "number", value: level }, 43 }); 44 if (blockType.data.value === "text") { 45 await rep?.mutate.assertFact({ 46 entity: entityID, 47 attribute: "block/type", 48 data: { type: "block-type-union", value: "heading" }, 49 }); 50 } 51 }, 52 [rep, focusedBlock, blockType], 53 ); 54 return ( 55 // This Toolbar should close once the user starts typing again 56 <> 57 <ToolbarButton 58 className={props.className} 59 onClick={() => { 60 setLevel(1); 61 }} 62 active={ 63 blockType?.data.value === "heading" && headingLevel?.data.value === 1 64 } 65 tooltipContent={ 66 <div className="flex flex-col justify-center"> 67 <div className="font-bold text-center">Title</div> 68 <div className="flex gap-1 font-normal"> 69 start line with 70 <ShortcutKey>#</ShortcutKey> 71 </div> 72 </div> 73 } 74 > 75 <Header1Small /> 76 </ToolbarButton> 77 <ToolbarButton 78 className={props.className} 79 onClick={() => { 80 setLevel(2); 81 }} 82 active={ 83 blockType?.data.value === "heading" && headingLevel?.data.value === 2 84 } 85 tooltipContent={ 86 <div className="flex flex-col justify-center"> 87 <div className="font-bold text-center">Heading</div> 88 <div className="flex gap-1 font-normal"> 89 start line with 90 <ShortcutKey>##</ShortcutKey> 91 </div> 92 </div> 93 } 94 > 95 <Header2Small /> 96 </ToolbarButton> 97 <ToolbarButton 98 className={props.className} 99 onClick={() => { 100 setLevel(3); 101 }} 102 active={ 103 blockType?.data.value === "heading" && headingLevel?.data.value === 3 104 } 105 tooltipContent={ 106 <div className="flex flex-col justify-center"> 107 <div className="font-bold text-center">Subheading</div> 108 <div className="flex gap-1 font-normal"> 109 start line with 110 <ShortcutKey>###</ShortcutKey> 111 </div> 112 </div> 113 } 114 > 115 <Header3Small /> 116 </ToolbarButton> 117 <Separator classname="h-6!" /> 118 <ToolbarButton 119 className={`px-[6px] ${props.className}`} 120 onClick={async () => { 121 if (headingLevel) 122 await rep?.mutate.retractFact({ factID: headingLevel.id }); 123 if (textSize) await rep?.mutate.retractFact({ factID: textSize.id }); 124 if (!focusedBlock || !blockType) return; 125 if (blockType.data.value !== "text") { 126 let existingEditor = 127 useEditorStates.getState().editorStates[focusedBlock.entityID]; 128 let selection = existingEditor?.editor.selection; 129 await rep?.mutate.assertFact({ 130 entity: focusedBlock?.entityID, 131 attribute: "block/type", 132 data: { type: "block-type-union", value: "text" }, 133 }); 134 135 let newEditor = 136 useEditorStates.getState().editorStates[focusedBlock.entityID]; 137 if (!newEditor || !selection) return; 138 newEditor.view?.dispatch( 139 newEditor.editor.tr.setSelection( 140 TextSelection.create(newEditor.editor.doc, selection.anchor), 141 ), 142 ); 143 144 newEditor.view?.focus(); 145 } 146 }} 147 active={ 148 blockType?.data.value === "text" && 149 textSize?.data.value !== "small" && 150 textSize?.data.value !== "large" 151 } 152 tooltipContent={<div>Normal Text</div>} 153 > 154 Text 155 </ToolbarButton> 156 <ToolbarButton 157 className={`px-[6px] text-lg ${props.className}`} 158 onClick={async () => { 159 if (!focusedBlock || !blockType) return; 160 if (blockType.data.value !== "text") { 161 // Convert to text block first if it's a heading 162 if (headingLevel) 163 await rep?.mutate.retractFact({ factID: headingLevel.id }); 164 await rep?.mutate.assertFact({ 165 entity: focusedBlock.entityID, 166 attribute: "block/type", 167 data: { type: "block-type-union", value: "text" }, 168 }); 169 } 170 // Set text size to large 171 await rep?.mutate.assertFact({ 172 entity: focusedBlock.entityID, 173 attribute: "block/text-size", 174 data: { type: "text-size-union", value: "large" }, 175 }); 176 }} 177 active={ 178 blockType?.data.value === "text" && textSize?.data.value === "large" 179 } 180 tooltipContent={<div>Large Text</div>} 181 > 182 <div className="leading-[1.625rem]">Large</div> 183 </ToolbarButton> 184 <ToolbarButton 185 className={`px-[6px] text-sm text-secondary ${props.className}`} 186 onClick={async () => { 187 if (!focusedBlock || !blockType) return; 188 if (blockType.data.value !== "text") { 189 // Convert to text block first if it's a heading 190 if (headingLevel) 191 await rep?.mutate.retractFact({ factID: headingLevel.id }); 192 await rep?.mutate.assertFact({ 193 entity: focusedBlock.entityID, 194 attribute: "block/type", 195 data: { type: "block-type-union", value: "text" }, 196 }); 197 } 198 // Set text size to small 199 await rep?.mutate.assertFact({ 200 entity: focusedBlock.entityID, 201 attribute: "block/text-size", 202 data: { type: "text-size-union", value: "small" }, 203 }); 204 }} 205 active={ 206 blockType?.data.value === "text" && textSize?.data.value === "small" 207 } 208 tooltipContent={<div>Small Text</div>} 209 > 210 <div className="leading-[1.625rem]">Small</div> 211 </ToolbarButton> 212 </> 213 ); 214}; 215 216export function TextBlockTypeButton(props: { 217 setToolbarState: (s: "heading") => void; 218 className?: string; 219}) { 220 return ( 221 <ToolbarButton 222 tooltipContent={<div>Text Size</div>} 223 className={`${props.className}`} 224 onClick={() => { 225 props.setToolbarState("heading"); 226 }} 227 > 228 <TextSizeSmall /> 229 </ToolbarButton> 230 ); 231} 232 233const TextSizeSmall = (props: Props) => { 234 return ( 235 <svg 236 width="24" 237 height="24" 238 viewBox="0 0 24 24" 239 fill="none" 240 xmlns="http://www.w3.org/2000/svg" 241 {...props} 242 > 243 <path 244 fillRule="evenodd" 245 clipRule="evenodd" 246 d="M14.3435 12.6008C14.4028 12.7825 14.6587 12.7855 14.7222 12.6052L14.8715 12.1816H19.0382L19.8657 14.6444C19.9067 14.7666 20.0212 14.8489 20.15 14.8489H21.6021C21.809 14.8489 21.9538 14.6443 21.885 14.4491L18.2831 4.23212C18.2408 4.11212 18.1274 4.03186 18.0002 4.03186H16.0009C15.8761 4.03186 15.7643 4.10917 15.7203 4.22598L13.5539 9.96923C13.5298 10.0331 13.5282 10.1033 13.5494 10.1682L14.3435 12.6008ZM18.5093 10.6076L17.0588 6.29056C17.0507 6.26644 17.0281 6.25019 17.0027 6.25019C16.9775 6.25019 16.9552 6.26605 16.9468 6.28974L15.4259 10.6076H18.5093ZM4.57075 19.9682C4.69968 19.9682 4.81418 19.8858 4.85518 19.7636L5.98945 16.3815H11.4579L12.5943 19.7637C12.6353 19.8859 12.7498 19.9682 12.8787 19.9682H15.0516C15.2586 19.9682 15.4034 19.7636 15.3346 19.5684L10.4182 5.62298C10.3759 5.50299 10.2625 5.42273 10.1353 5.42273H7.30723C7.17995 5.42273 7.06652 5.50305 7.02425 5.62311L2.11475 19.5686C2.04604 19.7637 2.19084 19.9682 2.39772 19.9682H4.57075ZM10.7468 14.2651L8.79613 8.45953C8.78532 8.42736 8.75517 8.40568 8.72123 8.40568C8.68728 8.40568 8.65712 8.42738 8.64633 8.45957L6.69928 14.2651H10.7468Z" 247 fill="currentColor" 248 /> 249 </svg> 250 ); 251};