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/check lists (#54)

* add checklists!

* tweak types and imports

* tweak list marker rendering to wrap properly

* added checkbox icons, spacing twaeking, WIP

* celine's very good work

* just use the same components for card preview

* use css variable to store unitless card width to calc ratio for preview

* remove unnessecary bg-white

* handle [] as checklist shortcut properly

* adjusted padding on the listMarker

* added checkbox to list toolbar, added a list submenu, small style tweak to shortcut key

---------

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

authored by

Jared Pereira
celine
and committed by
GitHub
abfc5401 4368dcfa

+564 -351
+18 -2
app/globals.css
··· 18 18 --highlight-2: 253, 245, 203; 19 19 --highlight-3: 224, 244, 255; 20 20 21 - --list-marker-width: 32px; 21 + --list-marker-width: 36px; 22 + --card-width-unitless: min(624, calc(var(--page-width-unitless) - 12)); 22 23 } 23 24 @media (max-width: 640px) { 24 25 :root { 25 - --list-marker-width: 16px; 26 + --list-marker-width: 20px; 27 + } 28 + } 29 + 30 + @media (min-width: 640px) { 31 + :root { 32 + --card-width-unitless: min(624, calc(var(--page-width-unitless) - 128)); 33 + } 34 + } 35 + 36 + @media (min-width: 1280px) { 37 + :root { 38 + --card-width-unitless: min( 39 + 624, 40 + calc((var(--page-width-unitless) / 2) - 32) 41 + ); 26 42 } 27 43 } 28 44
+25 -11
app/home/DocPreview.tsx
··· 12 12 import { deleteDoc } from "actions/deleteDoc"; 13 13 import { removeDocFromHome } from "./storage"; 14 14 import { mutate } from "swr"; 15 + import useMeasure from "react-use-measure"; 15 16 16 17 export const DocPreview = (props: { 17 18 token: PermissionToken; ··· 91 92 const DocContent = (props: { entityID: string }) => { 92 93 let blocks = useBlocks(props.entityID); 93 94 let previewRef = useRef<HTMLDivElement | null>(null); 95 + let [ref, dimensions] = useMeasure(); 94 96 95 97 return ( 96 98 <div 97 99 ref={previewRef} 98 - className={`cardBlockPreview w-full h-full overflow-clip flex flex-col gap-0.5 no-underline `} 100 + className={`cardBlockPreview w-full h-full overflow-clip flex flex-col gap-0.5 no-underline relative`} 99 101 > 100 - {blocks.slice(0, 10).map((b) => { 101 - return ( 102 - <BlockPreview 103 - previewRef={previewRef} 104 - {...b} 105 - key={b.factID} 106 - size="large" 107 - /> 108 - ); 109 - })} 102 + <div className="w-full" ref={ref} /> 103 + <div 104 + className="absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none" 105 + style={{ 106 + width: `calc(var(--card-width) * 1px)`, 107 + transform: `scale(calc(${dimensions.width} / var(--card-width)))`, 108 + }} 109 + > 110 + {blocks.slice(0, 10).map((b, index, arr) => { 111 + return ( 112 + <BlockPreview 113 + entityID={b.value} 114 + previousBlock={arr[index - 1] || null} 115 + nextBlock={arr[index + 1] || null} 116 + nextPosition={""} 117 + previewRef={previewRef} 118 + {...b} 119 + key={b.factID} 120 + /> 121 + ); 122 + })} 123 + </div> 110 124 </div> 111 125 ); 112 126 };
+13
app/layout.tsx
··· 36 36 return ( 37 37 <html lang="en" className={`${quattro.variable}`}> 38 38 <body> 39 + <script 40 + dangerouslySetInnerHTML={{ 41 + __html: ` 42 + let listener = () => { 43 + let el = document.querySelector(":root"); 44 + el.style.setProperty("--page-height-unitless", window.innerHeight) 45 + el.style.setProperty("--page-width-unitless", window.innerWidth) 46 + } 47 + listener() 48 + window.addEventListener("resize", listener) 49 + `, 50 + }} 51 + ></script> 39 52 <Analytics /> 40 53 <ServiceWorker /> 41 54 <InitialPageLoad>
+42 -143
components/Blocks/CardBlock.tsx
··· 1 1 "use client"; 2 - import { Block, BlockProps, focusBlock, ListMarker } from "components/Blocks"; 2 + import { 3 + BaseBlock, 4 + Block, 5 + BlockProps, 6 + focusBlock, 7 + ListMarker, 8 + } from "components/Blocks"; 3 9 import { focusCard } from "components/Cards"; 4 10 import { useEntity, useReplicache } from "src/replicache"; 5 11 import { useUIState } from "src/useUIState"; 6 12 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 7 13 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 8 14 import { CloseTiny } from "components/Icons"; 9 - import { useEffect, useRef, useState } from "react"; 15 + import { CSSProperties, useEffect, useRef, useState } from "react"; 10 16 import { useEntitySetContext } from "components/EntitySetProvider"; 11 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 12 18 13 - export function CardBlock(props: BlockProps) { 19 + export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { 14 20 let { rep } = useReplicache(); 15 21 let card = useEntity(props.entityID, "block/card"); 16 22 let cardEntity = card ? card.data.value : props.entityID; ··· 72 78 73 79 return ( 74 80 <div 81 + style={{ "--list-marker-width": "20px" } as CSSProperties} 75 82 className={` 76 83 cardBlockWrapper relative group/cardBlock 77 84 w-full h-[104px] ··· 131 138 <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip "> 132 139 {docMetadata[0] && ( 133 140 <div 134 - className={`cardBlockOne outline-none resize-none align-top flex gap-3 ${docMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 141 + className={`cardBlockOne outline-none resize-none align-top flex gap-2 ${docMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 135 142 > 136 143 {docMetadata[0].listData && ( 137 144 <ListMarker 138 145 {...docMetadata[0]} 139 - compact 140 146 className={ 141 147 docMetadata[0].type === "heading" 142 - ? "top-[10px]" 143 - : "top-[8px]" 148 + ? "!pt-[12px]" 149 + : "!pt-[8px]" 144 150 } 145 151 /> 146 152 )} ··· 149 155 )} 150 156 {docMetadata[1] && ( 151 157 <div 152 - className={`cardBlockLineTwo outline-none resize-none align-top flex gap-3 ${docMetadata[1].type === "heading" ? "font-bold" : ""}`} 158 + className={`cardBlockLineTwo outline-none resize-none align-top flex gap-2 ${docMetadata[1].type === "heading" ? "font-bold" : ""}`} 153 159 > 154 160 {docMetadata[1].listData && ( 155 - <ListMarker 156 - {...docMetadata[1]} 157 - compact 158 - className="top-[8px]" 159 - /> 161 + <ListMarker {...docMetadata[1]} className="!pt-[8px]" /> 160 162 )} 161 163 <RenderedTextBlock entityID={docMetadata[1].value} /> 162 164 </div> 163 165 )} 164 166 {docMetadata[2] && ( 165 167 <div 166 - className={`cardBlockLineThree outline-none resize-none align-top flex gap-3 ${docMetadata[2].type === "heading" ? "font-bold" : ""}`} 168 + className={`cardBlockLineThree outline-none resize-none align-top flex gap-2 ${docMetadata[2].type === "heading" ? "font-bold" : ""}`} 167 169 > 168 170 {docMetadata[2].listData && ( 169 - <ListMarker 170 - {...docMetadata[2]} 171 - compact 172 - className="top-[8px]" 173 - /> 171 + <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 174 172 )} 175 173 176 174 <RenderedTextBlock entityID={docMetadata[2].value} /> 177 175 </div> 178 176 )} 179 177 </div> 180 - <CardPreview entityID={cardEntity} /> 178 + {props.renderPreview && <CardPreview entityID={cardEntity} />} 181 179 {permission && ( 182 180 <button 183 181 className="absolute p-1 top-0.5 right-0.5 hover:text-accent-contrast text-secondary sm:hidden sm:group-hover/cardBlock:block" ··· 199 197 let blocks = useBlocks(props.entityID); 200 198 let previewRef = useRef<HTMLDivElement | null>(null); 201 199 200 + let cardWidth = `var(--card-width-unitless)`; 202 201 return ( 203 202 <div 204 203 ref={previewRef} 205 204 className={`cardBlockPreview w-[120px] overflow-clip p-1 mx-3 mt-3 -mb-2 bg-bg-card border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center`} 206 205 > 207 - {blocks.slice(0, 10).map((b) => { 208 - return <BlockPreview previewRef={previewRef} {...b} key={b.factID} />; 209 - })} 206 + <div 207 + className="absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none" 208 + style={{ 209 + width: `calc(1px * ${cardWidth})`, 210 + transform: `scale(calc((120 / ${cardWidth} )))`, 211 + }} 212 + > 213 + {blocks.slice(0, 10).map((b, index, arr) => { 214 + return ( 215 + <BlockPreview 216 + entityID={b.value} 217 + previousBlock={arr[index - 1] || null} 218 + nextBlock={arr[index + 1] || null} 219 + nextPosition={""} 220 + previewRef={previewRef} 221 + {...b} 222 + key={b.factID} 223 + /> 224 + ); 225 + })} 226 + </div> 210 227 </div> 211 228 ); 212 229 } 213 230 214 231 export function BlockPreview( 215 - b: Block & { 232 + b: BlockProps & { 216 233 previewRef: React.RefObject<HTMLDivElement>; 217 234 size?: "small" | "large"; 218 235 }, ··· 237 254 observer.observe(ref.current); 238 255 return () => observer.disconnect(); 239 256 }, [b.previewRef]); 240 - if (b.listData) 241 - return ( 242 - <div 243 - ref={ref} 244 - className="w-full flex flex-row" 245 - style={{ fontSize: "4px" }} 246 - > 247 - <div 248 - className="flex-shrink-0 relative" 249 - style={{ width: b.listData.depth * 4 }} 250 - > 251 - <div 252 - className={`absolute top-[] right-[2px] w-[1px] h-[1px] rounded-full bg-secondary ${ 253 - b.type === "heading" 254 - ? headingLevel === 3 255 - ? "top-[2.5px]" 256 - : headingLevel === 2 257 - ? "top-[3.5px]" 258 - : "top-[4px]" 259 - : "top-[2.5px]" 260 - }`} 261 - /> 262 - </div> 263 - 264 - {isVisible && <PreviewBlockContent {...b} size={b.size} />} 265 - </div> 266 - ); 267 - return ( 268 - <div ref={ref}> 269 - {isVisible && <PreviewBlockContent {...b} key={b.factID} size={b.size} />} 270 - </div> 271 - ); 272 - } 273 - 274 - function PreviewBlockContent(props: Block & { size?: "small" | "large" }) { 275 - switch (props.type) { 276 - case "text": { 277 - return ( 278 - <div style={{ fontSize: `${props.size === "large" ? "6px" : "4px"}` }}> 279 - <RenderedTextBlock entityID={props.value} className="p-0" /> 280 - </div> 281 - ); 282 - } 283 - case "heading": 284 - return <HeadingPreviewBlock entityID={props.value} size={props.size} />; 285 - // currently "link" and "card" render an identical preview 286 - case "link": { 287 - return ( 288 - <div 289 - // gradiend with 'primary' and 'tertiary' text colors as defined in tailwind config 290 - style={{ 291 - backgroundImage: 292 - "linear-gradient(355deg, color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-card)) 55%), rgb(var(--bg-card)))", 293 - }} 294 - className="w-full h-5 shrink-0 rounded-md border border-border-light" 295 - /> 296 - ); 297 - } 298 - case "card": 299 - return ( 300 - <div 301 - // gradiend with 'primary' and 'tertiary' text colors as defined in tailwind config 302 - style={{ 303 - backgroundImage: 304 - "linear-gradient(355deg, color-mix(in oklab, rgb(var(--primary)), rgb(var(--bg-card)) 55%), rgb(var(--bg-card)))", 305 - }} 306 - className="w-full h-5 shrink-0 rounded-md border border-border-light" 307 - /> 308 - ); 309 - case "image": 310 - return <ImagePreviewBlock entityID={props.value} />; 311 - default: 312 - null; 313 - } 314 - } 315 - 316 - function HeadingPreviewBlock(props: { 317 - entityID: string; 318 - size?: "small" | "large"; 319 - }) { 320 - let headingLevel = useEntity(props.entityID, "block/heading-level"); 321 - return ( 322 - <div 323 - className={ 324 - props.size === "large" 325 - ? LargeHeadingStyle[headingLevel?.data.value || 1] 326 - : HeadingStyle[headingLevel?.data.value || 1] 327 - } 328 - > 329 - <RenderedTextBlock entityID={props.entityID} className="p-0 " /> 330 - </div> 331 - ); 332 - } 333 - 334 - const HeadingStyle = { 335 - 1: "text-[6px] font-bold", 336 - 2: "text-[5px] font-bold ", 337 - 3: "text-[4px] font-bold italic text-secondary ", 338 - } as { [level: number]: string }; 339 - 340 - const LargeHeadingStyle = { 341 - 1: "text-[9px] font-bold", 342 - 2: "text-[7px] font-bold ", 343 - 3: "text-[6px] font-bold italic text-secondary ", 344 - } as { [level: number]: string }; 345 - 346 - function ImagePreviewBlock(props: { entityID: string }) { 347 - let image = useEntity(props.entityID, "block/image"); 348 - return ( 349 - <div className="relative group/image flex w-full justify-center"> 350 - <img 351 - alt={""} 352 - src={image?.data.src} 353 - height={image?.data.height} 354 - width={image?.data.width} 355 - className="" 356 - /> 357 - </div> 358 - ); 257 + return <div ref={ref}>{isVisible && <BaseBlock {...b} preview />}</div>; 359 258 }
+19 -29
components/Blocks/TextBlock/index.tsx
··· 42 42 import { highlightSelectionPlugin } from "./plugins"; 43 43 import { inputrules } from "./inputRules"; 44 44 45 - export function TextBlock(props: BlockProps & { className: string }) { 45 + export function TextBlock( 46 + props: BlockProps & { className: string; previewOnly?: boolean }, 47 + ) { 46 48 let initialized = useInitialPageLoad(); 47 49 let first = props.previousBlock === null; 48 50 let permission = useEntitySetContext().permissions.write; 49 - 50 - let blockPadding = `px-3 sm:px-4 ${ 51 - props.type === "heading" || (props.listData && props.nextBlock?.listData) 52 - ? "pb-0" 53 - : "pb-2" 54 - } 55 - ${props.listData ? "!pl-1" : ""} 56 - ${first ? "pt-2 sm:pt-3" : "pt-1"}`; 57 51 58 52 return ( 59 53 <> 60 - {(!initialized || !permission) && ( 54 + {(!initialized || !permission || props.previewOnly) && ( 61 55 <RenderedTextBlock 62 56 entityID={props.entityID} 63 - className={blockPadding + " " + props.className} 57 + className={props.className} 64 58 first={first} 65 59 /> 66 60 )} 67 - {permission && ( 61 + {permission && !props.previewOnly && ( 68 62 <div 69 63 className={`w-full relative group/text ${!initialized ? "hidden" : ""}`} 70 64 > 71 65 <IOSBS {...props} /> 72 - <BaseTextBlock blockPadding={blockPadding} {...props} /> 66 + <BaseTextBlock {...props} /> 73 67 </div> 74 68 )} 75 69 </> ··· 78 72 79 73 export function IOSBS(props: BlockProps) { 80 74 let selected = useUIState((s) => 81 - s.selectedBlock.find((b) => b.value === props.entityID) 75 + s.selectedBlock.find((b) => b.value === props.entityID), 82 76 ); 83 77 let [initialRender, setInitialRender] = useState(true); 84 78 useEffect(() => { ··· 97 91 }); 98 92 setTimeout(async () => { 99 93 let target = document.getElementById( 100 - elementId.block(props.entityID).container 94 + elementId.block(props.entityID).container, 101 95 ); 102 96 let vis = await isVisible(target as Element); 103 97 if (!vis) { 104 98 let parentEl = document.getElementById( 105 - elementId.card(props.parent).container 99 + elementId.card(props.parent).container, 106 100 ); 107 101 if (!parentEl) return; 108 102 parentEl?.scrollBy({ ··· 136 130 Y.applyUpdate(doc, update); 137 131 let nodes = doc.getXmlElement("prosemirror").toArray(); 138 132 139 - // show the rendered version of the block is the block has something in it! 133 + // show the rendered version of the block if the block has something in it! 140 134 // empty block rendering is handled further up. update both! 141 135 return ( 142 136 <pre ··· 152 146 ); 153 147 } 154 148 155 - export function BaseTextBlock( 156 - props: BlockProps & { className: string; blockPadding: string } 157 - ) { 149 + export function BaseTextBlock(props: BlockProps & { className: string }) { 158 150 const [mount, setMount] = useState<HTMLElement | null>(null); 159 151 160 152 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); ··· 169 161 }, [rep?.rep]); 170 162 171 163 let selected = useUIState((s) => 172 - s.selectedBlock.find((b) => b.value === props.entityID) 164 + s.selectedBlock.find((b) => b.value === props.entityID), 173 165 ); 174 166 let first = props.previousBlock === null; 175 167 let headingLevel = useEntity(props.entityID, "block/heading-level"); ··· 177 169 let [value, factID] = useYJSValue(props.entityID); 178 170 179 171 let editorState = useEditorStates( 180 - (s) => s.editorStates[props.entityID] 172 + (s) => s.editorStates[props.entityID], 181 173 )?.editor; 182 174 useEffect(() => { 183 175 if (!editorState) ··· 244 236 await addLinkBlock( 245 237 editorState.doc.textContent, 246 238 props.entityID, 247 - rep.rep 239 + rep.rep, 248 240 ); 249 241 } 250 242 }} ··· 268 260 className={` 269 261 grow resize-none align-top whitespace-pre-wrap bg-transparent 270 262 outline-none 271 - ${props.blockPadding} 272 263 ${props.className}`} 273 264 ref={setMount} 274 265 /> ··· 277 268 props.previousBlock === null && 278 269 props.nextBlock === null && ( 279 270 <div 280 - className={`${props.className} pointer-events-none absolute top-0 left-0 px-3 sm:px-4 pt-2 sm:pt-3 pb-2 italic text-tertiary `} 271 + className={`${props.className} pointer-events-none absolute top-0 left-0 italic text-tertiary `} 281 272 > 282 273 {props.type === "text" 283 274 ? "write something..." ··· 291 282 {/* if this is the block is empty and selected */} 292 283 {editorState.doc.textContent.length === 0 && selected && ( 293 284 <BlockOptions 294 - className={props.blockPadding} 295 285 factID={factID} 296 286 entityID={props.entityID} 297 287 parent={props.parent} ··· 332 322 ) { 333 323 toggleMark(args.mark, args.attrs)(view.state, view.dispatch); 334 324 } else setMark(args.mark, args.attrs)(view.state, view.dispatch); 335 - } 325 + }, 336 326 ); 337 327 useAppEventListener(props.entityID, "toggleMark", cb, []); 338 328 return null; ··· 359 349 360 350 // scroll card if cursor is at the very top or very bottom of the card 361 351 let parentID = document.getElementById( 362 - elementId.card(props.parentID).container 352 + elementId.card(props.parentID).container, 363 353 ); 364 354 let parentHeight = parentID?.clientHeight; 365 355 let cursorPosY = coords.top; ··· 394 384 }; 395 385 }); 396 386 }, 397 - [props.entityID] 387 + [props.entityID], 398 388 ); 399 389 return null; 400 390 };
+18 -5
components/Blocks/TextBlock/inputRules.ts
··· 14 14 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 15 15 ) => 16 16 inputRules({ 17 + //Strikethrough 17 18 rules: [ 18 19 new InputRule(/\~\~([^*]+)\~\~$/, (state, match, start, end) => { 19 20 const [fullMatch, content] = match; ··· 30 31 } 31 32 return null; 32 33 }), 34 + 35 + //Highlight 33 36 new InputRule(/\=\=([^*]+)\=\=$/, (state, match, start, end) => { 34 37 const [fullMatch, content] = match; 35 38 const { tr } = state; ··· 47 50 } 48 51 return null; 49 52 }), 53 + 54 + //Bold 50 55 new InputRule(/\*\*([^*]+)\*\*$/, (state, match, start, end) => { 51 56 const [fullMatch, content] = match; 52 57 const { tr } = state; ··· 62 67 } 63 68 return null; 64 69 }), 70 + 71 + //Italic 65 72 new InputRule(/(?:^|[^*])\*([^*]+)\*$/, (state, match, start, end) => { 66 73 const [fullMatch, content] = match; 67 74 const { tr } = state; ··· 78 85 } 79 86 return null; 80 87 }), 81 - new InputRule(/^([-+*])\s$/, (state) => { 82 - if (propsRef.current.listData) return null; 88 + 89 + //Checklist 90 + new InputRule(/^\[(\ |x)?\]\s$/, (state, match) => { 91 + if (!propsRef.current.listData) return null; 83 92 let tr = state.tr; 84 - tr.delete(0, 2); 93 + tr.delete(0, match[0].length); 85 94 repRef.current?.mutate.assertFact({ 86 95 entity: propsRef.current.entityID, 87 - attribute: "block/is-list", 88 - data: { type: "boolean", value: true }, 96 + attribute: "block/check-list", 97 + data: { type: "boolean", value: match[1] === "x" ? true : false }, 89 98 }); 90 99 return tr; 91 100 }), 101 + 102 + // Unordered List 92 103 new InputRule(/^([-+*])\s$/, (state) => { 93 104 if (propsRef.current.listData) return null; 94 105 let tr = state.tr; ··· 100 111 }); 101 112 return tr; 102 113 }), 114 + 115 + //Header 103 116 new InputRule(/^([#]{1,3})\s$/, (state, match) => { 104 117 let tr = state.tr; 105 118 tr.delete(0, match[0].length);
+35
components/Blocks/TextBlock/keymap.ts
··· 151 151 "Shift-Backspace": backspace(propsRef, repRef), 152 152 Enter: enter(propsRef, repRef), 153 153 "Shift-Enter": enter(propsRef, repRef), 154 + "Ctrl-Enter": CtrlEnter(propsRef, repRef), 155 + "Meta-Enter": CtrlEnter(propsRef, repRef), 154 156 }); 155 157 156 158 const backspace = ··· 170 172 return false; 171 173 } 172 174 if (propsRef.current.listData) { 175 + if (propsRef.current.listData.checklist) { 176 + repRef.current?.mutate.retractAttribute({ 177 + entity: propsRef.current.entityID, 178 + attribute: "block/check-list", 179 + }); 180 + return true; 181 + } 173 182 let depth = propsRef.current.listData.depth; 174 183 repRef.current?.mutate.moveChildren({ 175 184 oldParent: propsRef.current.entityID, ··· 327 336 attribute: "block/is-list", 328 337 data: { type: "boolean", value: true }, 329 338 }); 339 + let checked = await repRef.current?.query((tx) => 340 + scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 341 + ); 342 + if (checked?.[0]) 343 + await repRef.current?.mutate.assertFact({ 344 + entity: newEntityID, 345 + attribute: "block/check-list", 346 + data: { type: "boolean", value: false }, 347 + }); 330 348 } 349 + 331 350 if (!propsRef.current.listData) { 332 351 position = generateKeyBetween( 333 352 propsRef.current.position, ··· 386 405 }, 10); 387 406 return true; 388 407 }; 408 + 409 + const CtrlEnter = 410 + ( 411 + propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 412 + repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 413 + ) => 414 + ( 415 + state: EditorState, 416 + dispatch?: (tr: Transaction) => void, 417 + view?: EditorView, 418 + ) => { 419 + repRef.current?.mutate.toggleTodoState({ 420 + entityID: propsRef.current.entityID, 421 + }); 422 + return true; 423 + };
+103 -66
components/Blocks/index.tsx
··· 17 17 import { v7 } from "uuid"; 18 18 import { useBlockMouseHandlers } from "./useBlockMouseHandlers"; 19 19 import { indent, outdent } from "src/utils/list-operations"; 20 + import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; 20 21 export type Block = { 21 22 factID: string; 22 23 parent: string; ··· 24 25 value: string; 25 26 type: Fact<"block/type">["data"]["value"]; 26 27 listData?: { 28 + checklist?: boolean; 27 29 path: { depth: number; entity: string }[]; 28 30 parent: string; 29 31 depth: number; ··· 272 274 } 273 275 if (e.key === "ArrowDown") { 274 276 e.preventDefault(); 275 - let block = props.nextBlock; 276 - if (block && useUIState.getState().selectedBlock.length <= 1) 277 - focusBlock(block, { 277 + let nextBlock = props.nextBlock; 278 + if (nextBlock && useUIState.getState().selectedBlock.length <= 1) 279 + focusBlock(nextBlock, { 278 280 type: "top", 279 281 left: useEditorStates.getState().lastXPosition, 280 282 }); 281 - if (!block) return; 283 + if (!nextBlock) return; 282 284 } 283 285 if (e.key === "ArrowUp") { 284 286 e.preventDefault(); 285 - let block = props.previousBlock; 286 - if (block && useUIState.getState().selectedBlock.length <= 1) { 287 - focusBlock(block, { 287 + let prevBlock = props.previousBlock; 288 + if (prevBlock && useUIState.getState().selectedBlock.length <= 1) { 289 + focusBlock(prevBlock, { 288 290 type: "bottom", 289 291 left: useEditorStates.getState().lastXPosition, 290 292 }); 291 293 } 292 - if (!block) return; 294 + if (!prevBlock) return; 293 295 } 294 296 if (e.key === "Backspace") { 295 297 if (!entity_set.permissions.write) return; ··· 298 300 e.preventDefault(); 299 301 r.mutate.removeBlock({ blockEntity: props.entityID }); 300 302 useUIState.getState().closeCard(props.entityID); 301 - let block = props.previousBlock; 302 - if (block) focusBlock(block, { type: "end" }); 303 + let prevBlock = props.previousBlock; 304 + if (prevBlock) focusBlock(prevBlock, { type: "end" }); 303 305 } 304 306 if (e.key === "Enter") { 305 307 if (!entity_set.permissions.write) return; ··· 351 353 return () => window.removeEventListener("keydown", listener); 352 354 }, [entity_set, selected, props, rep]); 353 355 let mouseHandlers = useBlockMouseHandlers(props); 356 + 357 + let focusedElement = useUIState((s) => s.focusedBlock); 358 + 354 359 return ( 355 - <div className="blockWrapper relative flex"> 360 + <div {...mouseHandlers} className="blockWrapper relative flex"> 356 361 {selected && selectedBlocks.length > 1 && ( 357 362 <div 358 363 className={` 359 364 blockSelectionBG pointer-events-none bg-border-light 360 365 absolute right-2 left-2 bottom-0 366 + ${selectedBlocks.length > 1 ? "Multiple-Selected" : ""} 367 + ${actuallySelected ? "selected" : ""} 361 368 ${first ? "top-2" : "top-0"} 362 369 ${!prevBlockSelected && "rounded-t-md"} 363 370 ${!nextBlockSelected && "rounded-b-md"} 364 371 `} 365 372 /> 366 373 )} 367 - {props.listData && <ListMarker {...props} first={first} />} 368 - <div 369 - {...mouseHandlers} 370 - data-entityid={props.entityID} 371 - className={`blockContent relative grow flex flex-row 374 + <BaseBlock {...props} /> 375 + </div> 376 + ); 377 + } 378 + 379 + export const BaseBlock = (props: BlockProps & { preview?: boolean }) => { 380 + return ( 381 + <div 382 + data-entityid={props.entityID} 383 + className={` 384 + blockContent relative 385 + grow flex flex-row gap-2 386 + px-3 sm:px-4 372 387 ${ 373 - props.type !== "heading" && 374 - props.type !== "text" && 375 - `${first ? "pt-3 sm:pt-4" : "pt-1"} px-3 sm:px-4 pb-2` 388 + props.type === "heading" || 389 + (props.listData && props.nextBlock?.listData) 390 + ? "pb-0" 391 + : "pb-2" 376 392 } 377 - ${selectedBlocks.length > 1 ? "Multiple-Selected" : ""} 378 - ${actuallySelected ? "selected" : ""} 379 - `} 380 - id={elementId.block(props.entityID).container} 381 - > 382 - {props.type === "card" ? ( 383 - <CardBlock {...props} /> 384 - ) : props.type === "text" ? ( 385 - <TextBlock {...props} className="" /> 386 - ) : props.type === "heading" ? ( 387 - <HeadingBlock {...props} /> 388 - ) : props.type === "image" ? ( 389 - <ImageBlock {...props} /> 390 - ) : props.type === "link" ? ( 391 - <ExternalLinkBlock {...props} /> 392 - ) : null} 393 - </div> 393 + ${!props.previousBlock ? `${props.type === "heading" || props.type === "text" ? "pt-2 sm:pt-3" : "pt-3 sm:pt-4"}` : "pt-1"} 394 + `} 395 + id={elementId.block(props.entityID).container} 396 + > 397 + {props.listData && <ListMarker {...props} />} 398 + 399 + {props.type === "card" ? ( 400 + <CardBlock {...props} renderPreview={!props.preview} /> 401 + ) : props.type === "text" ? ( 402 + <TextBlock {...props} className="" previewOnly={props.preview} /> 403 + ) : props.type === "heading" ? ( 404 + <HeadingBlock {...props} preview={props.preview} /> 405 + ) : props.type === "image" ? ( 406 + <ImageBlock {...props} /> 407 + ) : props.type === "link" ? ( 408 + <ExternalLinkBlock {...props} /> 409 + ) : null} 394 410 </div> 395 411 ); 396 - } 412 + }; 397 413 398 414 export const ListMarker = ( 399 - props: Block & { first?: boolean; className?: string; compact?: boolean }, 415 + props: Block & { previousBlock?: Block | null; nextBlock?: Block | null } & { 416 + className?: string; 417 + }, 400 418 ) => { 419 + let checklist = useEntity(props.value, "block/check-list"); 401 420 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 402 421 let children = useEntity(props.value, "card/block"); 403 422 let folded = 404 423 useUIState((s) => s.foldedBlocks.includes(props.value)) && 405 424 children.length > 0; 425 + 426 + let depth = props.listData?.depth; 427 + let { rep } = useReplicache(); 406 428 return ( 407 - <> 429 + <div 430 + className={`shrink-0 flex gap-[8px] justify-end items-center h-3 431 + ${props.className} 432 + ${ 433 + props.type === "heading" 434 + ? headingLevel === 3 435 + ? "pt-[12px]" 436 + : headingLevel === 2 437 + ? "pt-[15px]" 438 + : "pt-[20px]" 439 + : "pt-[12px]" 440 + } 441 + `} 442 + style={{ 443 + width: 444 + depth && 445 + `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 12px)`} `, 446 + }} 447 + > 408 448 <button 409 449 onClick={() => { 410 450 if (children.length > 0) 411 451 useUIState.getState().toggleFold(props.value); 412 452 }} 413 - className={`listMarker group/list-marker pl-1 pr-2 sm:pr-3 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 453 + className={`listMarker group/list-marker ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 414 454 > 415 455 <div 416 - className="h-full shrink-0 flex justify-end relative" 417 - style={{ 418 - width: 419 - props.listData && 420 - `calc(${props.listData.depth} * ${ 421 - props.compact ? "16px" : `var(--list-marker-width))` 422 - }`, 456 + className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 457 + ${ 458 + folded 459 + ? "outline-secondary" 460 + : ` ${children.length > 0 ? "group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 461 + }`} 462 + /> 463 + </button> 464 + {checklist && ( 465 + <button 466 + onClick={() => { 467 + rep?.mutate.assertFact({ 468 + entity: props.value, 469 + attribute: "block/check-list", 470 + data: { type: "boolean", value: !checklist.data.value }, 471 + }); 423 472 }} 473 + className={`${checklist?.data.value ? "text-accent-contrast" : "text-border"}`} 424 474 > 425 - <div 426 - className={`absolute h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 427 - ${folded ? "outline-secondary" : ` ${children.length > 0 ? "group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}`} 428 - ${props.first ? "mt-1 sm:mt-2" : ""} 429 - ${ 430 - props.type === "heading" 431 - ? headingLevel === 3 432 - ? "top-[13px]" 433 - : headingLevel === 2 434 - ? "top-[15px]" 435 - : "top-[20px]" 436 - : "top-[13px]" 437 - } 438 - ${props.className}`} 439 - /> 440 - </div> 441 - </button> 442 - </> 475 + {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />} 476 + </button> 477 + )} 478 + </div> 443 479 ); 444 480 }; 445 481 ··· 449 485 3: "text-base font-bold text-secondary ", 450 486 } as { [level: number]: string }; 451 487 452 - export function HeadingBlock(props: BlockProps) { 488 + export function HeadingBlock(props: BlockProps & { preview?: boolean }) { 453 489 let headingLevel = useEntity(props.entityID, "block/heading-level"); 454 490 return ( 455 491 <TextBlock 456 492 {...props} 493 + previewOnly={props.preview} 457 494 className={HeadingStyle[headingLevel?.data.value || 1]} 458 495 /> 459 496 );
+2 -1
components/Cards.tsx
··· 104 104 id={elementId.card(props.entityID).container} 105 105 style={{ 106 106 backgroundColor: "rgba(var(--bg-card), var(--bg-card-alpha))", 107 + width: "calc(1px * var(--card-width-unitless))", 107 108 }} 108 109 className={` 109 - card w-[calc(100vw-12px)] sm:w-[calc(100vw-128px)] lg:w-[calc(50vw-32px)] max-w-prose 110 + card 110 111 grow flex flex-col 111 112 overscroll-y-none 112 113 overflow-y-scroll no-scrollbar
+58
components/Icons.tsx
··· 331 331 ); 332 332 }; 333 333 334 + export const CheckboxEmpty = (props: Props) => { 335 + return ( 336 + <svg 337 + width="12" 338 + height="12" 339 + viewBox="0 0 12 12" 340 + fill="none" 341 + xmlns="http://www.w3.org/2000/svg" 342 + > 343 + <path 344 + fill-Rule="evenodd" 345 + clipRule="evenodd" 346 + d="M0 1.875C0 0.839466 0.839466 0 1.875 0H10.125C11.1605 0 12 0.839466 12 1.875V10.125C12 11.1605 11.1605 12 10.125 12H1.875C0.839466 12 0 11.1605 0 10.125V1.875ZM1.875 1.25C1.52982 1.25 1.25 1.52982 1.25 1.875V10.125C1.25 10.4702 1.52982 10.75 1.875 10.75H10.125C10.4702 10.75 10.75 10.4702 10.75 10.125V1.875C10.75 1.52982 10.4702 1.25 10.125 1.25H1.875Z" 347 + fill="currentColor" 348 + /> 349 + </svg> 350 + ); 351 + }; 352 + export const CheckboxChecked = (props: Props) => { 353 + return ( 354 + <svg 355 + width="12" 356 + height="12" 357 + viewBox="0 0 12 12" 358 + fill="none" 359 + xmlns="http://www.w3.org/2000/svg" 360 + {...props} 361 + > 362 + <path 363 + fillRule="evenodd" 364 + clipRule="evenodd" 365 + d="M2 0C0.895431 0 0 0.89543 0 2V10C0 11.1046 0.89543 12 2 12H10C11.1046 12 12 11.1046 12 10V2C12 0.895431 11.1046 0 10 0H2ZM10.4554 3.95353C10.7769 3.59277 10.7451 3.03966 10.3843 2.71813C10.0236 2.3966 9.47046 2.4284 9.14893 2.78916L5.03209 7.40828L3.28982 5.92642C2.92171 5.61333 2.36949 5.65793 2.0564 6.02604C1.74331 6.39415 1.78791 6.94637 2.15602 7.25946L4.54955 9.29524C4.91165 9.60322 5.45339 9.56577 5.76966 9.21091L10.4554 3.95353Z" 366 + fill="currentColor" 367 + /> 368 + </svg> 369 + ); 370 + }; 371 + 334 372 export const CloseTiny = (props: Props) => { 335 373 return ( 336 374 <svg ··· 708 746 fillRule="evenodd" 709 747 clipRule="evenodd" 710 748 d="M8.27716 5.19995C7.72488 5.19995 7.27716 5.64767 7.27716 6.19995C7.27716 6.75224 7.72488 7.19995 8.27716 7.19995H19.6546C20.2069 7.19995 20.6546 6.75224 20.6546 6.19995C20.6546 5.64767 20.2069 5.19995 19.6546 5.19995H8.27716ZM4.46208 7.10005C4.95913 7.10005 5.36208 6.69711 5.36208 6.20005C5.36208 5.70299 4.95913 5.30005 4.46208 5.30005C3.96502 5.30005 3.56208 5.70299 3.56208 6.20005C3.56208 6.69711 3.96502 7.10005 4.46208 7.10005ZM5.36208 12.0001C5.36208 12.4972 4.95913 12.9001 4.46208 12.9001C3.96502 12.9001 3.56208 12.4972 3.56208 12.0001C3.56208 11.503 3.96502 11.1001 4.46208 11.1001C4.95913 11.1001 5.36208 11.503 5.36208 12.0001ZM8.27716 11C7.72488 11 7.27716 11.4477 7.27716 12C7.27716 12.5523 7.72488 13 8.27716 13H20.106C20.6583 13 21.106 12.5523 21.106 12C21.106 11.4477 20.6583 11 20.106 11H8.27716ZM11.1218 17.8001C11.1218 18.2972 10.7189 18.7001 10.2218 18.7001C9.72479 18.7001 9.32184 18.2972 9.32184 17.8001C9.32184 17.3031 9.72479 16.9001 10.2218 16.9001C10.7189 16.9001 11.1218 17.3031 11.1218 17.8001ZM14.0372 16.8C13.4849 16.8 13.0372 17.2478 13.0372 17.8C13.0372 18.3523 13.4849 18.8 14.0372 18.8L19.6549 18.8C20.2071 18.8 20.6549 18.3523 20.6549 17.8C20.6549 17.2478 20.2071 16.8 19.6549 16.8L14.0372 16.8ZM4.48975 17.1753L5.4936 16.1721C5.78659 15.8793 5.78675 15.4044 5.49395 15.1114C5.20115 14.8185 4.72628 14.8183 4.43329 15.1111L1.74243 17.8002L4.43329 20.4892C4.72628 20.782 5.20115 20.7819 5.49395 20.4889C5.78675 20.1959 5.78659 19.721 5.4936 19.4282L4.48999 18.4253H6.96185C7.30703 18.4253 7.58685 18.1455 7.58685 17.8003C7.58685 17.4551 7.30703 17.1753 6.96185 17.1753H4.48975Z" 749 + fill="currentColor" 750 + /> 751 + </svg> 752 + ); 753 + }; 754 + 755 + export const ListCheckboxSmall = (props: Props) => { 756 + return ( 757 + <svg 758 + width="24" 759 + height="24" 760 + viewBox="0 0 24 24" 761 + fill="none" 762 + xmlns="http://www.w3.org/2000/svg" 763 + {...props} 764 + > 765 + <path 766 + fillRule="evenodd" 767 + clipRule="evenodd" 768 + 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" 711 769 fill="currentColor" 712 770 /> 713 771 </svg>
+1 -1
components/Layout.tsx
··· 29 29 export const ShortcutKey = (props: { children: React.ReactNode }) => { 30 30 return ( 31 31 <span> 32 - <code className="min-w-6 w-fit text-xs text-primary bg-border-light border border-secondary rounded-md px-0.5 flex justify-center "> 32 + <code className="min-w-6 w-fit text-xs text-primary bg-border-light border border-secondary rounded-md px-0.5 flex justify-center font-bold "> 33 33 {props.children} 34 34 </code> 35 35 </span>
+16 -1
components/ThemeManager/ThemeProvider.tsx
··· 1 1 "use client"; 2 2 3 - import { CSSProperties, useEffect } from "react"; 3 + import { CSSProperties, useEffect, useState } from "react"; 4 4 import { colorToString, useColorAttribute } from "./useColorAttribute"; 5 5 import { Color as AriaColor, parseColor } from "react-aria-components"; 6 6 import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn"; ··· 110 110 accent2, 111 111 accentContrast, 112 112 ]); 113 + let [canonicalCardWidth, setCanonicalCardWidth] = useState(0); 114 + useEffect(() => { 115 + let listener = () => { 116 + let el = document.getElementById("canonical-card-width"); 117 + setCanonicalCardWidth(el?.clientWidth || 0); 118 + }; 119 + listener(); 120 + window.addEventListener("resize", listener); 121 + return () => window.removeEventListener("resize", listener); 122 + }, []); 113 123 return ( 114 124 <div 115 125 className="pageWrapper w-full text-primary h-full flex flex-col bg-center items-stretch" 116 126 style={ 117 127 { 128 + "--card-width": canonicalCardWidth, 118 129 "--bg-page": colorToString(bgPage, "rgb"), 119 130 "--bg-card": colorToString(bgCard, "rgb"), 120 131 "--bg-card-alpha": bgCard.getChannelValue("alpha"), ··· 130 141 } as CSSProperties 131 142 } 132 143 > 144 + <div 145 + className="h-[0px] w-[calc(100vw-12px)] sm:w-[calc(100vw-128px)] lg:w-[calc(50vw-32px)] max-w-prose" 146 + id="canonical-card-width" 147 + /> 133 148 {props.children} 134 149 </div> 135 150 );
+2 -2
components/Toolbar/LinkButton.tsx
··· 10 10 import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 11 11 import { Input } from "components/Input"; 12 12 13 - export function LinkButton(props: { setToolBarState: (s: "link") => void }) { 13 + export function LinkButton(props: { setToolbarState: (s: "link") => void }) { 14 14 let focusedBlock = useUIState((s) => s.focusedBlock); 15 15 let focusedEditor = useEditorStates((s) => 16 16 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, ··· 33 33 active={isLink} 34 34 onClick={(e) => { 35 35 e.preventDefault(); 36 - props.setToolBarState("link"); 36 + props.setToolbarState("link"); 37 37 }} 38 38 disabled={focusedEditor?.editor.selection.empty || !focusedEditor} 39 39 tooltipContent={
+163
components/Toolbar/ListButton.tsx
··· 1 + import { 2 + ArrowRightTiny, 3 + ListCheckboxSmall, 4 + ListIndentDecreaseSmall, 5 + ListIndentIncreaseSmall, 6 + ListUnorderedSmall, 7 + } from "components/Icons"; 8 + import { Separator, ShortcutKey } from "components/Layout"; 9 + import { useBlocks } from "src/hooks/queries/useBlocks"; 10 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { useUIState } from "src/useUIState"; 12 + import { metaKey } from "src/utils/metaKey"; 13 + import { ToolbarButton } from "."; 14 + import { indent, outdent } from "src/utils/list-operations"; 15 + import { useEffect } from "react"; 16 + import { useEditorStates } from "src/state/useEditorState"; 17 + 18 + export const ListButton = (props: { setToolbarState: (s: "list") => void }) => { 19 + let focusedBlock = useUIState((s) => s.focusedBlock); 20 + let isList = useEntity(focusedBlock?.entityID || null, "block/is-list"); 21 + 22 + let { rep } = useReplicache(); 23 + 24 + return ( 25 + <div className="flex items-center gap-1"> 26 + <ToolbarButton 27 + tooltipContent={ 28 + <div className="flex flex-col gap-1 justify-center"> 29 + <div className="text-center">Make List</div> 30 + <div className="flex gap-1"> 31 + { 32 + <> 33 + <ShortcutKey> {metaKey()}</ShortcutKey> +{" "} 34 + <ShortcutKey> Alt </ShortcutKey> +{" "} 35 + <ShortcutKey> L </ShortcutKey> 36 + </> 37 + } 38 + </div> 39 + </div> 40 + } 41 + onClick={() => { 42 + if (!focusedBlock) return; 43 + if (!isList?.data.value) { 44 + rep?.mutate.assertFact({ 45 + entity: focusedBlock?.entityID, 46 + attribute: "block/is-list", 47 + data: { value: true, type: "boolean" }, 48 + }); 49 + } 50 + props.setToolbarState("list"); 51 + }} 52 + > 53 + <ListUnorderedSmall /> 54 + </ToolbarButton> 55 + {isList?.data.value && ( 56 + <ToolbarButton 57 + tooltipContent="List Options" 58 + onClick={() => { 59 + props.setToolbarState("list"); 60 + }} 61 + className="-ml-1" 62 + > 63 + <ArrowRightTiny /> 64 + </ToolbarButton> 65 + )} 66 + </div> 67 + ); 68 + }; 69 + 70 + export const ListToolbar = (props: { onClose: () => void }) => { 71 + let focusedBlock = useUIState((s) => s.focusedBlock); 72 + let siblings = useBlocks( 73 + focusedBlock?.type === "block" ? focusedBlock.parent : null, 74 + ); 75 + 76 + let isCheckbox = useEntity( 77 + focusedBlock?.entityID || null, 78 + "block/check-list", 79 + ); 80 + let isList = useEntity(focusedBlock?.entityID || null, "block/is-list"); 81 + 82 + let block = siblings.find((s) => s.value === focusedBlock?.entityID); 83 + let previousBlock = 84 + siblings[siblings.findIndex((b) => b.value === focusedBlock?.entityID) - 1]; 85 + let { rep } = useReplicache(); 86 + 87 + useEffect(() => { 88 + if (!isList?.data.value) props.onClose(); 89 + }, [props, isList]); 90 + 91 + return ( 92 + <div className="flex items-center gap-[6px]"> 93 + <ToolbarButton 94 + tooltipContent={ 95 + <div className="flex flex-col gap-1 justify-center"> 96 + <div className="text-center">Indent Item</div> 97 + <div className="flex gap-1 justify-center"> 98 + <ShortcutKey>Tab</ShortcutKey> 99 + </div> 100 + </div> 101 + } 102 + disabled={ 103 + !previousBlock?.listData || 104 + previousBlock.listData.depth !== block?.listData?.depth 105 + } 106 + onClick={() => { 107 + if (!rep || !block || !previousBlock) return; 108 + indent(block, previousBlock, rep); 109 + }} 110 + > 111 + <ListIndentIncreaseSmall /> 112 + </ToolbarButton> 113 + <ToolbarButton 114 + disabled={!isList?.data.value} 115 + tooltipContent={ 116 + <div className="flex flex-col gap-1 justify-center"> 117 + <div className="text-center">Outdent Item</div> 118 + <div className="flex gap-1 justify-center"> 119 + <ShortcutKey>Shift</ShortcutKey> + <ShortcutKey>Tab</ShortcutKey> 120 + </div> 121 + </div> 122 + } 123 + onClick={() => { 124 + if (!rep || !block) return; 125 + outdent(block, previousBlock, rep); 126 + }} 127 + > 128 + <ListIndentDecreaseSmall /> 129 + </ToolbarButton> 130 + <Separator classname="h-6" /> 131 + <ToolbarButton 132 + disabled={!isList?.data.value} 133 + tooltipContent=<div className="flex flex-col gap-1 justify-center"> 134 + <div className="text-center">Add a Checkbox</div> 135 + <div className="flex gap-1 font-normal"> 136 + start line with <ShortcutKey>[</ShortcutKey> 137 + <ShortcutKey>]</ShortcutKey> 138 + </div> 139 + </div> 140 + onClick={() => { 141 + if (!focusedBlock) return; 142 + 143 + if (!isCheckbox) { 144 + rep?.mutate.assertFact({ 145 + entity: focusedBlock.entityID, 146 + attribute: "block/check-list", 147 + data: { type: "boolean", value: false }, 148 + }); 149 + } else { 150 + rep?.mutate.retractFact({ 151 + factID: isCheckbox.id, 152 + }); 153 + } 154 + }} 155 + > 156 + <ListCheckboxSmall /> 157 + </ToolbarButton> 158 + </div> 159 + ); 160 + }; 161 + function setInitialRender(arg0: boolean) { 162 + throw new Error("Function not implemented."); 163 + }
+5 -88
components/Toolbar/index.tsx
··· 36 36 import { addShortcut } from "src/shortcuts"; 37 37 import { useBlocks } from "src/hooks/queries/useBlocks"; 38 38 import { indent, outdent } from "src/utils/list-operations"; 39 + import { ListButton, ListToolbar } from "./ListButton"; 39 40 40 41 export const TextToolbar = (props: { cardID: string; blockID: string }) => { 41 42 let { rep } = useReplicache(); ··· 173 174 </ToolbarButton> 174 175 </div> 175 176 <Separator classname="h-6" /> 176 - 177 - <ListToolbar /> 178 - 177 + <ListButton setToolbarState={setToolbarState} /> 179 178 <Separator classname="h-6" /> 180 - <LinkButton setToolBarState={setToolbarState} /> 179 + <LinkButton setToolbarState={setToolbarState} /> 181 180 <Separator classname="h-6" /> 182 181 <TextBlockTypeButton setToolbarState={setToolbarState} /> 183 182 </> ··· 189 188 setLastUsedHighlight(color) 190 189 } 191 190 /> 191 + ) : toolbarState === "list" ? ( 192 + <ListToolbar onClose={() => setToolbarState("default")} /> 192 193 ) : toolbarState === "link" ? ( 193 194 <LinkEditor 194 195 onClose={() => { ··· 262 263 <Separator classname="h-6" /> 263 264 <HighlightColorSettings /> 264 265 </div> 265 - </div> 266 - ); 267 - }; 268 - 269 - const ListToolbar = () => { 270 - let focusedBlock = useUIState((s) => s.focusedBlock); 271 - let isList = useEntity(focusedBlock?.entityID || null, "block/is-list"); 272 - let siblings = useBlocks( 273 - focusedBlock?.type === "block" ? focusedBlock.parent : null, 274 - ); 275 - let block = siblings.find((s) => s.value === focusedBlock?.entityID); 276 - let previousBlock = 277 - siblings[siblings.findIndex((b) => b.value === focusedBlock?.entityID) - 1]; 278 - let { rep } = useReplicache(); 279 - if (!isList?.data.value) 280 - return ( 281 - <div className="flex justify-between items-center gap-4"> 282 - <ToolbarButton 283 - tooltipContent={ 284 - <div className="flex flex-col gap-1 justify-center"> 285 - <div className="text-center">Make List</div> 286 - <div className="flex gap-1"> 287 - { 288 - <> 289 - <ShortcutKey> {metaKey()}</ShortcutKey> +{" "} 290 - <ShortcutKey> Alt </ShortcutKey> +{" "} 291 - <ShortcutKey> L </ShortcutKey> 292 - </> 293 - } 294 - </div> 295 - </div> 296 - } 297 - onClick={() => { 298 - if (!focusedBlock) return; 299 - rep?.mutate.assertFact({ 300 - entity: focusedBlock?.entityID, 301 - attribute: "block/is-list", 302 - data: { value: true, type: "boolean" }, 303 - }); 304 - }} 305 - > 306 - <ListUnorderedSmall /> 307 - </ToolbarButton> 308 - </div> 309 - ); 310 - 311 - return ( 312 - <div className="flex items-center gap-[6px]"> 313 - <ToolbarButton 314 - tooltipContent={ 315 - <div className="flex flex-col gap-1 justify-center"> 316 - <div className="text-center">Indent Item</div> 317 - <div className="flex gap-1 justify-center"> 318 - <ShortcutKey>Tab</ShortcutKey> 319 - </div> 320 - </div> 321 - } 322 - disabled={ 323 - !previousBlock?.listData || 324 - previousBlock.listData.depth !== block?.listData?.depth 325 - } 326 - onClick={() => { 327 - if (!rep || !block || !previousBlock) return; 328 - indent(block, previousBlock, rep); 329 - }} 330 - > 331 - <ListIndentIncreaseSmall /> 332 - </ToolbarButton> 333 - <ToolbarButton 334 - tooltipContent={ 335 - <div className="flex flex-col gap-1 justify-center"> 336 - <div className="text-center">Outdent Item</div> 337 - <div className="flex gap-1 justify-center"> 338 - <ShortcutKey>Shift</ShortcutKey> + <ShortcutKey>Tab</ShortcutKey> 339 - </div> 340 - </div> 341 - } 342 - onClick={() => { 343 - if (!rep || !block) return; 344 - outdent(block, previousBlock, rep); 345 - }} 346 - > 347 - <ListIndentDecreaseSmall /> 348 - </ToolbarButton> 349 266 </div> 350 267 ); 351 268 };
+10 -1
src/hooks/queries/useBlocks.ts
··· 51 51 await scan.eav(root.data.value, "card/block") 52 52 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 53 53 let type = (await scan.eav(root.data.value, "block/type"))[0]; 54 + let checklist = await scan.eav( 55 + root.data.value, 56 + "block/check-list", 57 + ); 54 58 if (!type) return []; 55 59 let newPath = [...path, { entity: root.data.value, depth }]; 56 60 let childBlocks = await Promise.all( ··· 64 68 factID: root.id, 65 69 type: type.data.value, 66 70 parent: b.entity, 67 - listData: { depth: depth, parent, path: newPath }, 71 + listData: { 72 + depth: depth, 73 + parent, 74 + path: newPath, 75 + checklist: !!checklist[0], 76 + }, 68 77 }, 69 78 ...childBlocks.flat(), 70 79 ];
+4
src/replicache/attributes.ts
··· 14 14 type: "boolean", 15 15 cardinality: "one", 16 16 }, 17 + "block/check-list": { 18 + type: "boolean", 19 + cardinality: "one", 20 + }, 17 21 "block/text": { 18 22 type: "text", 19 23 cardinality: "one",
+30 -1
src/replicache/mutations.ts
··· 1 1 import { DeepReadonly } from "replicache"; 2 2 import { Fact } from "."; 3 - import { Attributes } from "./attributes"; 3 + import { Attributes, FilterAttributes } from "./attributes"; 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; ··· 362 362 }); 363 363 }; 364 364 365 + const retractAttribute: Mutation<{ 366 + entity: string; 367 + attribute: keyof FilterAttributes<{ cardinality: "one" }>; 368 + }> = async (args, ctx) => { 369 + let fact = (await ctx.scanIndex.eav(args.entity, args.attribute))[0]; 370 + if (fact) await ctx.retractFact(fact.id); 371 + }; 372 + 373 + const toggleTodoState: Mutation<{ entityID: string }> = async (args, ctx) => { 374 + let [checked] = await ctx.scanIndex.eav(args.entityID, "block/check-list"); 375 + if (!checked) { 376 + await ctx.assertFact({ 377 + entity: args.entityID, 378 + attribute: "block/check-list", 379 + data: { type: "boolean", value: false }, 380 + }); 381 + } else if (!checked.data.value) { 382 + await ctx.assertFact({ 383 + entity: args.entityID, 384 + attribute: "block/check-list", 385 + data: { type: "boolean", value: true }, 386 + }); 387 + } else { 388 + await ctx.retractFact(checked.id); 389 + } 390 + }; 391 + 365 392 export const mutations = { 393 + retractAttribute, 366 394 addBlock, 367 395 addLastBlock, 368 396 outdentBlock, ··· 375 403 removeBlock, 376 404 moveChildren, 377 405 increaseHeadingLevel, 406 + toggleTodoState, 378 407 };