a tool for shared writing and social publishing

Feat/Ordered Lists (#257)

* implement ordered lists

* fix readme command

authored by

Grahame Watt and committed by
GitHub
289d915b da31fd1d

+1004 -240
+75
README.md
··· 25 26 Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/). 27 28 ## Technical details 29 30 The stack:
··· 25 26 Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/). 27 28 + ### Local Development (Linux, WSL) 29 + 30 + #### Prerequisites 31 + 32 + - [NodeJS](https://nodejs.org/en) (version 20 or later) 33 + - [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started) 34 + - [Docker](https://docker.com) (required for local Supabase) 35 + 36 + #### Installation 37 + 38 + 1. Clone the repository `git clone https://tangled.org/leaflet.pub/leaflet.git` 39 + 1. If using WSL, it's recommended to install in the native file structure vs in a mounted Windows file structure (i.e, prefer installing at `~/code/leaflet` vs `/mnt/c/code/leaflet`) 40 + 2. Install the dependencies: `npm install` 41 + 3. Install the Supabase CLI: 42 + - **macOS:** `brew install supabase/tap/supabase` 43 + - **Windows:** `scoop bucket add supabase https://github.com/supabase/scoop-bucket.git && scoop install supabase` 44 + - **Linux:** Use Homebrew or download packages from [releases page](https://github.com/supabase/cli/releases) 45 + - **Via npm:** The CLI is already included in package.json, use `npx supabase` for commands 46 + 47 + #### Local Supabase Setup 48 + 49 + 1. Start the local Supabase stack: `npx supabase start` 50 + - First run takes longer while Docker images download 51 + - Once complete, you'll see connection details in the terminal output 52 + - Keep note of the `API URL`, `anon key`, `service_role key`, and `DB URL` 53 + 2. Copy the `.env` file example to `.env.local` and update with your local values from the previous step: 54 + 55 + ```env 56 + # Supabase Configuration (from `supabase start` output) 57 + NEXT_PUBLIC_SUPABASE_API_URL=http://localhost:54321 58 + NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key-from-terminal 59 + SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key-from-terminal 60 + 61 + # Database (default local connection) 62 + DB_URL=postgresql://postgres:postgres@localhost:54322/postgres 63 + 64 + # Leaflet specific 65 + LEAFLET_APP_PASSWORD=any-password-you-want 66 + 67 + # Feed Service (for publication features, optional) 68 + FEED_SERVICE_URL=http://localhost:3001 69 + ``` 70 + 71 + #### Database Migrations 72 + 73 + 1. Apply migrations to your local database: 74 + - First time setup: `npx supabase db reset` (resets database and applies all migrations) 75 + - Apply new migrations only: `npx supabase migration up` (applies unapplied migrations) 76 + - Note: You don't need to link to a remote project for local development 77 + 2. Access Supabase Studio at `http://localhost:54323` to view your local database 78 + 79 + #### Running the App 80 + 81 + 1. `npm run dev` to start the development server 82 + 2. Visit `http://localhost:3000` in your browser 83 + 84 + #### Stopping Local Supabase 85 + 86 + - Run `npx supabase stop` to stop the local Supabase stack 87 + - Add `--no-backup` flag to reset the database on next start 88 + 89 + #### Feed service setup (optional) 90 + 91 + Setup instructions to run a local feed service from a docker container. This step isn't necessary if you're not working on publication or BlueSky integration features. 92 + 93 + 1. Clone the repo `git clone https://github.com/hyperlink-academy/leaflet-feeds.git` 94 + 2. Update your `.env.local` to include the FEED_SERVICE_URL (if not already set): `FEED_SERVICE_URL=http://localhost:3001` 95 + 3. Change to the directory and build the docker container `docker build -t leaflet-feeds .` 96 + 4. Run the docker container on port 3001 (to avoid conflicts with the main app): `docker run -p 3001:3000 leaflet-feeds` 97 + 98 + #### Troubleshooting 99 + 100 + - Persisting articles on a fresh install over a fresh DB are usually due to stale Replicache entrys. To clear, open your browser DevTools and delete Replicache entries (usually under IndexedDB Storage) 101 + - Supabase settings will get cached in `.next`; if you change where you're pointing your supabase connections to you may need to delete the `.next` folder (it will rebuild next time you start the app). 102 + 103 ## Technical details 104 105 The stack:
+1 -1
actions/publishToPublication.ts
··· 41 import { Json } from "supabase/database.types"; 42 import { $Typed, UnicodeString } from "@atproto/api"; 43 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 44 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 45 import { Lock } from "src/utils/lock"; 46 import type { PubLeafletPublication } from "lexicons/api"; 47 import {
··· 41 import { Json } from "supabase/database.types"; 42 import { $Typed, UnicodeString } from "@atproto/api"; 43 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 44 + import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 45 import { Lock } from "src/utils/lock"; 46 import type { PubLeafletPublication } from "lexicons/api"; 47 import {
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 6 import { drizzle } from "drizzle-orm/node-postgres"; 7 import { email_subscriptions_to_entity } from "drizzle/schema"; 8 import postgres from "postgres"; 9 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 10 import type { Fact, PermissionToken } from "src/replicache"; 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types";
··· 6 import { drizzle } from "drizzle-orm/node-postgres"; 7 import { email_subscriptions_to_entity } from "drizzle/schema"; 8 import postgres from "postgres"; 9 + import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 10 import type { Fact, PermissionToken } from "src/replicache"; 11 import type { Attribute } from "src/replicache/attributes"; 12 import { Database } from "supabase/database.types";
+73
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 6 PubLeafletBlocksImage, 7 PubLeafletBlocksText, 8 PubLeafletBlocksUnorderedList, 9 PubLeafletBlocksWebsite, 10 PubLeafletDocument, 11 PubLeafletPagesLinearDocument, ··· 238 </ul> 239 ); 240 } 241 case PubLeafletBlocksMath.isMain(b.block): { 242 return <StaticMathBlock block={b.block} />; 243 } ··· 459 </li> 460 ); 461 }
··· 6 PubLeafletBlocksImage, 7 PubLeafletBlocksText, 8 PubLeafletBlocksUnorderedList, 9 + PubLeafletBlocksOrderedList, 10 PubLeafletBlocksWebsite, 11 PubLeafletDocument, 12 PubLeafletPagesLinearDocument, ··· 239 </ul> 240 ); 241 } 242 + case PubLeafletBlocksOrderedList.isMain(b.block): { 243 + return ( 244 + <ol className="-ml-px sm:ml-[9px] pb-2" start={b.block.startIndex || 1}> 245 + {b.block.children.map((child, i) => ( 246 + <OrderedListItem 247 + pollData={pollData} 248 + pages={pages} 249 + bskyPostData={bskyPostData} 250 + index={[...index, i]} 251 + item={child} 252 + did={did} 253 + key={i} 254 + className={className} 255 + pageId={pageId} 256 + startIndex={b.block.startIndex || 1} 257 + /> 258 + ))} 259 + </ol> 260 + ); 261 + } 262 case PubLeafletBlocksMath.isMain(b.block): { 263 return <StaticMathBlock block={b.block} />; 264 } ··· 480 </li> 481 ); 482 } 483 + 484 + function OrderedListItem(props: { 485 + index: number[]; 486 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 487 + item: PubLeafletBlocksOrderedList.ListItem; 488 + did: string; 489 + className?: string; 490 + bskyPostData: AppBskyFeedDefs.PostView[]; 491 + pollData: PollData[]; 492 + pageId?: string; 493 + startIndex?: number; 494 + }) { 495 + const calculatedIndex = (props.startIndex || 1) + props.index[props.index.length - 1]; 496 + let children = props.item.children?.length ? ( 497 + <ol className="-ml-[7px] sm:ml-[7px]"> 498 + {props.item.children.map((child, index) => ( 499 + <OrderedListItem 500 + pages={props.pages} 501 + pollData={props.pollData} 502 + bskyPostData={props.bskyPostData} 503 + index={[...props.index, index]} 504 + item={child} 505 + did={props.did} 506 + key={index} 507 + className={props.className} 508 + pageId={props.pageId} 509 + startIndex={props.startIndex} 510 + /> 511 + ))} 512 + </ol> 513 + ) : null; 514 + return ( 515 + <li className={`pb-0! flex flex-row gap-2`}> 516 + <div className="listMarker shrink-0 mx-2 z-1 mt-[14px]"> 517 + {calculatedIndex}. 518 + </div> 519 + <div className="flex flex-col w-full"> 520 + <Block 521 + pollData={props.pollData} 522 + pages={props.pages} 523 + bskyPostData={props.bskyPostData} 524 + block={{ block: props.item.content }} 525 + did={props.did} 526 + isList 527 + index={props.index} 528 + pageId={props.pageId} 529 + /> 530 + {children}{" "} 531 + </div> 532 + </li> 533 + ); 534 + }
+87 -11
components/Blocks/Block.tsx
··· 26 import { BlueskyPostBlock } from "./BlueskyPostBlock"; 27 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 28 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 29 - import { LockTiny } from "components/Icons/LockTiny"; 30 import { MathBlock } from "./MathBlock"; 31 import { CodeBlock } from "./CodeBlock"; 32 import { HorizontalRule } from "./HorizontalRule"; 33 import { deepEquals } from "src/utils/deepEquals"; 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 - import { focusPage } from "src/utils/focusPage"; 36 import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 import { Separator } from "components/Layout"; ··· 47 type: Fact<"block/type">["data"]["value"]; 48 listData?: { 49 checklist?: boolean; 50 path: { depth: number; entity: string }[]; 51 parent: string; 52 depth: number; ··· 192 if ( 193 prevProps.listData.checklist !== nextProps.listData.checklist || 194 prevProps.listData.parent !== nextProps.listData.parent || 195 - prevProps.listData.depth !== nextProps.listData.depth 196 ) { 197 return false; 198 } ··· 495 ) => { 496 let isMobile = useIsMobile(); 497 let checklist = useEntity(props.value, "block/check-list"); 498 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 499 let children = useEntity(props.value, "card/block"); 500 let folded = ··· 504 let depth = props.listData?.depth; 505 let { permissions } = useEntitySetContext(); 506 let { rep } = useReplicache(); 507 return ( 508 <div 509 className={`shrink-0 flex justify-end items-center h-3 z-1 ··· 531 }} 532 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 533 > 534 - <div 535 - className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1 536 - ${ 537 - folded 538 - ? "outline-secondary" 539 - : ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 540 - }`} 541 - /> 542 </button> 543 {checklist && ( 544 <button
··· 26 import { BlueskyPostBlock } from "./BlueskyPostBlock"; 27 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 28 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 29 import { MathBlock } from "./MathBlock"; 30 import { CodeBlock } from "./CodeBlock"; 31 import { HorizontalRule } from "./HorizontalRule"; 32 import { deepEquals } from "src/utils/deepEquals"; 33 import { isTextBlock } from "src/utils/isTextBlock"; 34 import { DeleteTiny } from "components/Icons/DeleteTiny"; 35 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 36 import { Separator } from "components/Layout"; ··· 45 type: Fact<"block/type">["data"]["value"]; 46 listData?: { 47 checklist?: boolean; 48 + listStyle?: "ordered" | "unordered"; 49 + listStart?: number; 50 + displayNumber?: number; 51 path: { depth: number; entity: string }[]; 52 parent: string; 53 depth: number; ··· 193 if ( 194 prevProps.listData.checklist !== nextProps.listData.checklist || 195 prevProps.listData.parent !== nextProps.listData.parent || 196 + prevProps.listData.depth !== nextProps.listData.depth || 197 + prevProps.listData.displayNumber !== nextProps.listData.displayNumber || 198 + prevProps.listData.listStyle !== nextProps.listData.listStyle 199 ) { 200 return false; 201 } ··· 498 ) => { 499 let isMobile = useIsMobile(); 500 let checklist = useEntity(props.value, "block/check-list"); 501 + let listStyle = useEntity(props.value, "block/list-style"); 502 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 503 let children = useEntity(props.value, "card/block"); 504 let folded = ··· 508 let depth = props.listData?.depth; 509 let { permissions } = useEntitySetContext(); 510 let { rep } = useReplicache(); 511 + 512 + let [editingNumber, setEditingNumber] = useState(false); 513 + let [numberInputValue, setNumberInputValue] = useState(""); 514 + 515 + useEffect(() => { 516 + if (!editingNumber) { 517 + setNumberInputValue(""); 518 + } 519 + }, [editingNumber]); 520 + 521 + const handleNumberSave = async () => { 522 + if (!rep || !props.listData) return; 523 + 524 + const newNumber = parseInt(numberInputValue, 10); 525 + if (isNaN(newNumber) || newNumber < 1) { 526 + setEditingNumber(false); 527 + return; 528 + } 529 + 530 + const currentDisplay = props.listData.displayNumber || 1; 531 + 532 + if (newNumber === currentDisplay) { 533 + // Remove override if it matches the computed number 534 + await rep.mutate.retractAttribute({ 535 + entity: props.value, 536 + attribute: "block/list-number", 537 + }); 538 + } else { 539 + await rep.mutate.assertFact({ 540 + entity: props.value, 541 + attribute: "block/list-number", 542 + data: { type: "number", value: newNumber }, 543 + }); 544 + } 545 + 546 + setEditingNumber(false); 547 + }; 548 return ( 549 <div 550 className={`shrink-0 flex justify-end items-center h-3 z-1 ··· 572 }} 573 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 574 > 575 + {listStyle?.data.value === "ordered" ? ( 576 + editingNumber ? ( 577 + <input 578 + type="text" 579 + value={numberInputValue} 580 + onChange={(e) => setNumberInputValue(e.target.value)} 581 + onClick={(e) => e.stopPropagation()} 582 + onBlur={handleNumberSave} 583 + onKeyDown={(e) => { 584 + if (e.key === "Enter") { 585 + e.preventDefault(); 586 + handleNumberSave(); 587 + } else if (e.key === "Escape") { 588 + setEditingNumber(false); 589 + } 590 + }} 591 + autoFocus 592 + className="text-secondary font-normal text-right min-w-[2rem] w-[2rem] border border-border rounded-md px-1 py-0.5 focus:border-tertiary focus:outline-solid focus:outline-tertiary focus:outline-2 focus:outline-offset-1" 593 + /> 594 + ) : ( 595 + <div 596 + className="text-secondary font-normal text-right w-[2rem] cursor-pointer hover:text-primary" 597 + onClick={(e) => { 598 + e.stopPropagation(); 599 + if (permissions.write && listStyle?.data.value === "ordered") { 600 + setNumberInputValue(String(props.listData?.displayNumber || 1)); 601 + setEditingNumber(true); 602 + } 603 + }} 604 + > 605 + {props.listData?.displayNumber || 1}. 606 + </div> 607 + ) 608 + ) : ( 609 + <div 610 + className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1 611 + ${ 612 + folded 613 + ? "outline-secondary" 614 + : ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 615 + }`} 616 + /> 617 + )} 618 </button> 619 {checklist && ( 620 <button
+23 -2
components/Blocks/BlockCommands.tsx
··· 28 } from "components/Icons/BlockTextSmall"; 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 - import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; ··· 151 }, 152 }, 153 { 154 - name: "List", 155 icon: <ListUnorderedSmall />, 156 type: "text", 157 onSelect: async (rep, props, um) => { ··· 161 attribute: "block/is-list", 162 data: { value: true, type: "boolean" }, 163 }); 164 clearCommandSearchText(entity); 165 }, 166 },
··· 28 } from "components/Icons/BlockTextSmall"; 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 + import { ListUnorderedSmall, ListOrderedSmall } from "components/Toolbar/ListToolbar"; 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; ··· 151 }, 152 }, 153 { 154 + name: "Unordered List", 155 icon: <ListUnorderedSmall />, 156 type: "text", 157 onSelect: async (rep, props, um) => { ··· 161 attribute: "block/is-list", 162 data: { value: true, type: "boolean" }, 163 }); 164 + clearCommandSearchText(entity); 165 + }, 166 + }, 167 + { 168 + name: "Ordered List", 169 + icon: <ListOrderedSmall />, 170 + type: "text", 171 + onSelect: async (rep, props, um) => { 172 + let entity = await createBlockWithType(rep, props, "text"); 173 + await rep?.mutate.assertFact([ 174 + { 175 + entity, 176 + attribute: "block/is-list", 177 + data: { value: true, type: "boolean" }, 178 + }, 179 + { 180 + entity, 181 + attribute: "block/list-style", 182 + data: { value: "ordered", type: "list-style-union" }, 183 + }, 184 + ]); 185 clearCommandSearchText(entity); 186 }, 187 },
+1 -1
components/Blocks/MailboxBlock.tsx
··· 13 import { focusPage } from "src/utils/focusPage"; 14 import { v7 } from "uuid"; 15 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 16 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 17 import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 18 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 19 import {
··· 13 import { focusPage } from "src/utils/focusPage"; 14 import { v7 } from "uuid"; 15 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 16 + import { getBlocksWithType } from "src/replicache/getBlocks"; 17 import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 18 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 19 import {
+40 -5
components/Blocks/TextBlock/inputRules.ts
··· 154 if (propsRef.current.listData) return null; 155 let tr = state.tr; 156 tr.delete(0, 2); 157 - repRef.current?.mutate.assertFact({ 158 - entity: propsRef.current.entityID, 159 - attribute: "block/is-list", 160 - data: { type: "boolean", value: true }, 161 - }); 162 return tr; 163 }), 164
··· 154 if (propsRef.current.listData) return null; 155 let tr = state.tr; 156 tr.delete(0, 2); 157 + repRef.current?.mutate.assertFact([ 158 + { 159 + entity: propsRef.current.entityID, 160 + attribute: "block/is-list", 161 + data: { type: "boolean", value: true }, 162 + }, 163 + { 164 + entity: propsRef.current.entityID, 165 + attribute: "block/list-style", 166 + data: { type: "list-style-union", value: "unordered" }, 167 + }, 168 + ]); 169 + return tr; 170 + }), 171 + 172 + // Ordered List - respect the starting number typed (supports "1." or "1)") 173 + new InputRule(/^(\d+)[.)]\s$/, (state, match) => { 174 + if (propsRef.current.listData) return null; 175 + let tr = state.tr; 176 + tr.delete(0, match[0].length); 177 + const startNumber = parseInt(match[1], 10); 178 + repRef.current?.mutate.assertFact([ 179 + { 180 + entity: propsRef.current.entityID, 181 + attribute: "block/is-list", 182 + data: { type: "boolean", value: true }, 183 + }, 184 + { 185 + entity: propsRef.current.entityID, 186 + attribute: "block/list-style", 187 + data: { type: "list-style-union", value: "ordered" }, 188 + }, 189 + ]); 190 + if (startNumber > 1) { 191 + repRef.current?.mutate.assertFact({ 192 + entity: propsRef.current.entityID, 193 + attribute: "block/list-number", 194 + data: { type: "number", value: startNumber }, 195 + }); 196 + } 197 return tr; 198 }), 199
+37 -4
components/Blocks/TextBlock/keymap.ts
··· 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 type PropsRef = RefObject< ··· 369 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 370 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 371 ) => 372 - () => { 373 if (useUIState.getState().selectedBlocks.length > 1) return false; 374 if (!repRef.current) return false; 375 if (!repRef.current) return false; 376 - outdent(propsRef.current, propsRef.current.previousBlock, repRef.current); 377 return true; 378 }; 379 ··· 423 y: position.data.position.y + box.height, 424 }, 425 }); 426 - if (propsRef.current.listData) 427 await repRef.current?.mutate.assertFact({ 428 entity: newEntityID, 429 attribute: "block/is-list", 430 data: { type: "boolean", value: true }, 431 }); 432 return; 433 } 434 if (propsRef.current.listData) { ··· 499 attribute: "block/is-list", 500 data: { type: "boolean", value: true }, 501 }); 502 let checked = await repRef.current?.query((tx) => 503 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 504 );
··· 21 import { v7 } from "uuid"; 22 import { scanIndex } from "src/replicache/utils"; 23 import { indent, outdent } from "src/utils/list-operations"; 24 + import { getBlocksWithType } from "src/replicache/getBlocks"; 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 import { UndoManager } from "src/undoManager"; 27 type PropsRef = RefObject< ··· 369 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 370 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 371 ) => 372 + async () => { 373 if (useUIState.getState().selectedBlocks.length > 1) return false; 374 if (!repRef.current) return false; 375 if (!repRef.current) return false; 376 + let { foldedBlocks, toggleFold } = useUIState.getState(); 377 + await outdent(propsRef.current, propsRef.current.previousBlock, repRef.current, { 378 + foldedBlocks, 379 + toggleFold, 380 + }); 381 return true; 382 }; 383 ··· 427 y: position.data.position.y + box.height, 428 }, 429 }); 430 + if (propsRef.current.listData) { 431 await repRef.current?.mutate.assertFact({ 432 entity: newEntityID, 433 attribute: "block/is-list", 434 data: { type: "boolean", value: true }, 435 }); 436 + // Copy list style for canvas blocks 437 + let listStyle = await repRef.current?.query((tx) => 438 + scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 439 + ); 440 + if (listStyle?.[0]) { 441 + await repRef.current?.mutate.assertFact({ 442 + entity: newEntityID, 443 + attribute: "block/list-style", 444 + data: { 445 + type: "list-style-union", 446 + value: listStyle[0].data.value, 447 + }, 448 + }); 449 + } 450 + } 451 return; 452 } 453 if (propsRef.current.listData) { ··· 518 attribute: "block/is-list", 519 data: { type: "boolean", value: true }, 520 }); 521 + // Copy list style (ordered/unordered) to new list item 522 + let listStyle = await repRef.current?.query((tx) => 523 + scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 524 + ); 525 + if (listStyle?.[0]) { 526 + await repRef.current?.mutate.assertFact({ 527 + entity: newEntityID, 528 + attribute: "block/list-style", 529 + data: { 530 + type: "list-style-union", 531 + value: listStyle[0].data.value, 532 + }, 533 + }); 534 + } 535 let checked = await repRef.current?.query((tx) => 536 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 537 );
+28 -8
components/Blocks/TextBlock/useHandlePaste.ts
··· 87 if ( 88 !(children.length === 1 && children[0].tagName === "IMG" && hasImage) 89 ) { 90 children.forEach((child, index) => { 91 createBlockFromHTML(child, { 92 undoManager, ··· 95 activeBlockProps: propsRef, 96 entity_set, 97 rep, 98 - parent: propsRef.current.listData 99 - ? propsRef.current.listData.parent 100 - : propsRef.current.parent, 101 getPosition: () => { 102 currentPosition = generateKeyBetween( 103 currentPosition || null, ··· 169 getPosition, 170 parent, 171 parentType, 172 }: { 173 parentType: "canvas" | "doc"; 174 parent: string; ··· 179 undoManager: UndoManager; 180 entity_set: { set: string }; 181 getPosition: () => string; 182 }, 183 ) => { 184 let type: Fact<"block/type">["data"]["value"] | null; 185 let headingLevel: number | null = null; 186 let hasChildren = false; 187 188 - if (child.tagName === "UL") { 189 let children = Array.from(child.children); 190 if (children.length > 0) hasChildren = true; 191 for (let c of children) { 192 createBlockFromHTML(c, { 193 first: first && c === children[0], ··· 199 getPosition, 200 parent, 201 parentType, 202 }); 203 } 204 } ··· 482 } 483 484 if (child.tagName === "LI") { 485 - let ul = Array.from(child.children) 486 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) 487 - .find((f) => f.tagName === "UL"); 488 let checked = child.getAttribute("data-checked"); 489 if (checked !== null) { 490 rep.mutate.assertFact({ ··· 498 attribute: "block/is-list", 499 data: { type: "boolean", value: true }, 500 }); 501 - if (ul) { 502 hasChildren = true; 503 let currentPosition: string | null = null; 504 - createBlockFromHTML(ul, { 505 parentType, 506 first: false, 507 last: last, ··· 514 return currentPosition; 515 }, 516 parent: entityID, 517 }); 518 } 519 } ··· 605 "H6", 606 "LI", 607 "UL", 608 "IMG", 609 "A", 610 "SPAN",
··· 87 if ( 88 !(children.length === 1 && children[0].tagName === "IMG" && hasImage) 89 ) { 90 + const pasteParent = propsRef.current.listData 91 + ? propsRef.current.listData.parent 92 + : propsRef.current.parent; 93 + 94 children.forEach((child, index) => { 95 createBlockFromHTML(child, { 96 undoManager, ··· 99 activeBlockProps: propsRef, 100 entity_set, 101 rep, 102 + parent: pasteParent, 103 getPosition: () => { 104 currentPosition = generateKeyBetween( 105 currentPosition || null, ··· 171 getPosition, 172 parent, 173 parentType, 174 + listStyle, 175 + depth = 1, 176 }: { 177 parentType: "canvas" | "doc"; 178 parent: string; ··· 183 undoManager: UndoManager; 184 entity_set: { set: string }; 185 getPosition: () => string; 186 + listStyle?: "ordered" | "unordered"; 187 + depth?: number; 188 }, 189 ) => { 190 let type: Fact<"block/type">["data"]["value"] | null; 191 let headingLevel: number | null = null; 192 let hasChildren = false; 193 194 + if (child.tagName === "UL" || child.tagName === "OL") { 195 let children = Array.from(child.children); 196 if (children.length > 0) hasChildren = true; 197 + const childListStyle = child.tagName === "OL" ? "ordered" : "unordered"; 198 for (let c of children) { 199 createBlockFromHTML(c, { 200 first: first && c === children[0], ··· 206 getPosition, 207 parent, 208 parentType, 209 + listStyle: childListStyle, 210 + depth, 211 }); 212 } 213 } ··· 491 } 492 493 if (child.tagName === "LI") { 494 + // Look for nested UL or OL 495 + let nestedList = Array.from(child.children) 496 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) 497 + .find((f) => f.tagName === "UL" || f.tagName === "OL"); 498 let checked = child.getAttribute("data-checked"); 499 if (checked !== null) { 500 rep.mutate.assertFact({ ··· 508 attribute: "block/is-list", 509 data: { type: "boolean", value: true }, 510 }); 511 + // Set list style if provided (from parent OL/UL) 512 + if (listStyle) { 513 + rep.mutate.assertFact({ 514 + entity: entityID, 515 + attribute: "block/list-style", 516 + data: { type: "list-style-union", value: listStyle }, 517 + }); 518 + } 519 + if (nestedList) { 520 hasChildren = true; 521 let currentPosition: string | null = null; 522 + createBlockFromHTML(nestedList, { 523 parentType, 524 first: false, 525 last: last, ··· 532 return currentPosition; 533 }, 534 parent: entityID, 535 + depth: depth + 1, 536 }); 537 } 538 } ··· 624 "H6", 625 "LI", 626 "UL", 627 + "OL", 628 "IMG", 629 "A", 630 "SPAN",
+7 -4
components/Blocks/useBlockKeyboardHandlers.ts
··· 62 return; 63 64 undoManager.startGroup(); 65 - command?.({ 66 e, 67 props, 68 rep, ··· 88 89 const AllowedIfTextBlock = ["Tab"]; 90 91 - function Tab({ e, props, rep }: Args) { 92 // if tab or shift tab, indent or outdent 93 if (useUIState.getState().selectedBlocks.length > 1) return false; 94 if (e.shiftKey) { 95 e.preventDefault(); 96 - outdent(props, props.previousBlock, rep); 97 } else { 98 e.preventDefault(); 99 - if (props.previousBlock) indent(props, props.previousBlock, rep); 100 } 101 } 102
··· 62 return; 63 64 undoManager.startGroup(); 65 + await command?.({ 66 e, 67 props, 68 rep, ··· 88 89 const AllowedIfTextBlock = ["Tab"]; 90 91 + async function Tab({ e, props, rep }: Args) { 92 // if tab or shift tab, indent or outdent 93 if (useUIState.getState().selectedBlocks.length > 1) return false; 94 + let { foldedBlocks, toggleFold } = useUIState.getState(); 95 if (e.shiftKey) { 96 e.preventDefault(); 97 + await outdent(props, props.previousBlock, rep, { foldedBlocks, toggleFold }); 98 } else { 99 e.preventDefault(); 100 + if (props.previousBlock) { 101 + await indent(props, props.previousBlock, rep, { foldedBlocks, toggleFold }); 102 + } 103 } 104 } 105
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 5 import { isTextBlock } from "src/utils/isTextBlock"; 6 import { useEntitySetContext } from "components/EntitySetProvider"; 7 import { useReplicache } from "src/replicache"; 8 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 9 import { focusBlock } from "src/utils/focusBlock"; 10 import { useIsMobile } from "src/hooks/isMobile"; 11 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
··· 5 import { isTextBlock } from "src/utils/isTextBlock"; 6 import { useEntitySetContext } from "components/EntitySetProvider"; 7 import { useReplicache } from "src/replicache"; 8 + import { getBlocksWithType } from "src/replicache/getBlocks"; 9 import { focusBlock } from "src/utils/focusBlock"; 10 import { useIsMobile } from "src/hooks/isMobile"; 11 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
+8 -23
components/SelectionManager/index.tsx
··· 6 import { focusBlock } from "src/utils/focusBlock"; 7 import { useEditorStates } from "src/state/useEditorState"; 8 import { useEntitySetContext } from "../EntitySetProvider"; 9 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 11 import { addShortcut, Shortcut } from "src/shortcuts"; 12 import { elementId } from "src/utils/elementId"; 13 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; ··· 486 let [sortedSelection, siblings] = await getSortedSelectionBound(); 487 if (sortedSelection.length <= 1) return; 488 e.preventDefault(); 489 if (e.shiftKey) { 490 - for (let i = siblings.length - 1; i >= 0; i--) { 491 - let block = siblings[i]; 492 - if (!sortedSelection.find((s) => s.value === block.value)) 493 - continue; 494 - if ( 495 - sortedSelection.find((s) => s.value === block.listData?.parent) 496 - ) 497 - continue; 498 - let parentoffset = 1; 499 - let previousBlock = siblings[i - parentoffset]; 500 - while ( 501 - previousBlock && 502 - sortedSelection.find((s) => previousBlock.value === s.value) 503 - ) { 504 - parentoffset += 1; 505 - previousBlock = siblings[i - parentoffset]; 506 - } 507 - if (!block.listData || !previousBlock.listData) continue; 508 - outdent(block, previousBlock, rep); 509 - } 510 } else { 511 for (let i = 0; i < siblings.length; i++) { 512 let block = siblings[i]; ··· 526 previousBlock = siblings[i - parentoffset]; 527 } 528 if (!block.listData || !previousBlock.listData) continue; 529 - indent(block, previousBlock, rep); 530 } 531 } 532 }
··· 6 import { focusBlock } from "src/utils/focusBlock"; 7 import { useEditorStates } from "src/state/useEditorState"; 8 import { useEntitySetContext } from "../EntitySetProvider"; 9 + import { getBlocksWithType } from "src/replicache/getBlocks"; 10 + import { indent, outdentFull, multiSelectOutdent } from "src/utils/list-operations"; 11 import { addShortcut, Shortcut } from "src/shortcuts"; 12 import { elementId } from "src/utils/elementId"; 13 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; ··· 486 let [sortedSelection, siblings] = await getSortedSelectionBound(); 487 if (sortedSelection.length <= 1) return; 488 e.preventDefault(); 489 + 490 if (e.shiftKey) { 491 + let { foldedBlocks, toggleFold } = useUIState.getState(); 492 + await multiSelectOutdent(sortedSelection, siblings, rep, { foldedBlocks, toggleFold }); 493 } else { 494 for (let i = 0; i < siblings.length; i++) { 495 let block = siblings[i]; ··· 509 previousBlock = siblings[i - parentoffset]; 510 } 511 if (!block.listData || !previousBlock.listData) continue; 512 + let { foldedBlocks, toggleFold } = useUIState.getState(); 513 + 514 + await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 515 } 516 } 517 }
+1 -1
components/SelectionManager/selectionState.ts
··· 2 import { Replicache } from "replicache"; 3 import { ReplicacheMutators } from "src/replicache"; 4 import { useUIState } from "src/useUIState"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 7 export const useSelectingMouse = create(() => ({ 8 start: null as null | string,
··· 2 import { Replicache } from "replicache"; 3 import { ReplicacheMutators } from "src/replicache"; 4 import { useUIState } from "src/useUIState"; 5 + import { getBlocksWithType } from "src/replicache/getBlocks"; 6 7 export const useSelectingMouse = create(() => ({ 8 start: null as null | string,
+80 -5
components/Toolbar/ListToolbar.tsx
··· 4 import { useUIState } from "src/useUIState"; 5 import { metaKey } from "src/utils/metaKey"; 6 import { ToolbarButton } from "."; 7 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 8 import { useEffect } from "react"; 9 import { Props } from "components/Icons/Props"; 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; ··· 70 71 export const ListToolbar = (props: { onClose: () => void }) => { 72 let focusedBlock = useUIState((s) => s.focusedEntity); 73 let siblings = useBlocks( 74 focusedBlock?.entityType === "block" ? focusedBlock.parent : null, 75 ); ··· 104 </div> 105 </div> 106 } 107 - onClick={() => { 108 if (!rep || !block) return; 109 - outdent(block, previousBlock, rep); 110 }} 111 > 112 <ListIndentDecreaseSmall /> ··· 124 !previousBlock?.listData || 125 previousBlock.listData.depth < block?.listData?.depth! 126 } 127 - onClick={() => { 128 if (!rep || !block || !previousBlock) return; 129 - indent(block, previousBlock, rep); 130 }} 131 > 132 <ListIndentIncreaseSmall /> ··· 134 <Separator classname="h-6!" /> 135 <ToolbarButton 136 disabled={!isList?.data.value} 137 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 138 <div className="text-center">Add a Checkbox</div> 139 <div className="flex gap-1 font-normal"> ··· 179 d="M8.1687 5.19995C7.61642 5.19995 7.1687 5.64767 7.1687 6.19995C7.1687 6.75224 7.61642 7.19995 8.1687 7.19995H19.5461C20.0984 7.19995 20.5461 6.75224 20.5461 6.19995C20.5461 5.64767 20.0984 5.19995 19.5461 5.19995H8.1687ZM4.35361 7.10005C4.85067 7.10005 5.25361 6.69711 5.25361 6.20005C5.25361 5.70299 4.85067 5.30005 4.35361 5.30005C3.85656 5.30005 3.45361 5.70299 3.45361 6.20005C3.45361 6.69711 3.85656 7.10005 4.35361 7.10005ZM5.25361 12.0001C5.25361 12.4972 4.85067 12.9001 4.35361 12.9001C3.85656 12.9001 3.45361 12.4972 3.45361 12.0001C3.45361 11.503 3.85656 11.1001 4.35361 11.1001C4.85067 11.1001 5.25361 11.503 5.25361 12.0001ZM8.1687 11C7.61642 11 7.1687 11.4477 7.1687 12C7.1687 12.5523 7.61642 13 8.1687 13H19.5461C20.0984 13 20.5461 12.5523 20.5461 12C20.5461 11.4477 20.0984 11 19.5461 11H8.1687ZM5.25361 17.8001C5.25361 18.2972 4.85067 18.7001 4.35361 18.7001C3.85656 18.7001 3.45361 18.2972 3.45361 17.8001C3.45361 17.3031 3.85656 16.9001 4.35361 16.9001C4.85067 16.9001 5.25361 17.3031 5.25361 17.8001ZM8.1687 16.8C7.61642 16.8 7.1687 17.2478 7.1687 17.8C7.1687 18.3523 7.61642 18.8 8.1687 18.8H19.5461C20.0984 18.8 20.5461 18.3523 20.5461 17.8C20.5461 17.2478 20.0984 16.8 19.5461 16.8H8.1687Z" 180 fill="currentColor" 181 /> 182 </svg> 183 ); 184 };
··· 4 import { useUIState } from "src/useUIState"; 5 import { metaKey } from "src/utils/metaKey"; 6 import { ToolbarButton } from "."; 7 + import { indent, outdent, outdentFull, orderListItems, unorderListItems } from "src/utils/list-operations"; 8 import { useEffect } from "react"; 9 import { Props } from "components/Icons/Props"; 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; ··· 70 71 export const ListToolbar = (props: { onClose: () => void }) => { 72 let focusedBlock = useUIState((s) => s.focusedEntity); 73 + let foldedBlocks = useUIState((s) => s.foldedBlocks); 74 + let toggleFold = useUIState((s) => s.toggleFold); 75 let siblings = useBlocks( 76 focusedBlock?.entityType === "block" ? focusedBlock.parent : null, 77 ); ··· 106 </div> 107 </div> 108 } 109 + onClick={async () => { 110 if (!rep || !block) return; 111 + await outdent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 112 }} 113 > 114 <ListIndentDecreaseSmall /> ··· 126 !previousBlock?.listData || 127 previousBlock.listData.depth < block?.listData?.depth! 128 } 129 + onClick={async () => { 130 if (!rep || !block || !previousBlock) return; 131 + await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 132 }} 133 > 134 <ListIndentIncreaseSmall /> ··· 136 <Separator classname="h-6!" /> 137 <ToolbarButton 138 disabled={!isList?.data.value} 139 + tooltipContent="Unordered List" 140 + onClick={() => { 141 + if (!block || !rep) return; 142 + unorderListItems(block, rep); 143 + }} 144 + > 145 + <ListUnorderedSmall /> 146 + </ToolbarButton> 147 + <ToolbarButton 148 + disabled={!isList?.data.value} 149 + tooltipContent="Ordered List" 150 + onClick={() => { 151 + if (!block || !rep) return; 152 + orderListItems(block, rep); 153 + }} 154 + > 155 + <ListOrderedSmall /> 156 + </ToolbarButton> 157 + <Separator classname="h-6" /> 158 + <ToolbarButton 159 + disabled={!isList?.data.value} 160 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 161 <div className="text-center">Add a Checkbox</div> 162 <div className="flex gap-1 font-normal"> ··· 202 d="M8.1687 5.19995C7.61642 5.19995 7.1687 5.64767 7.1687 6.19995C7.1687 6.75224 7.61642 7.19995 8.1687 7.19995H19.5461C20.0984 7.19995 20.5461 6.75224 20.5461 6.19995C20.5461 5.64767 20.0984 5.19995 19.5461 5.19995H8.1687ZM4.35361 7.10005C4.85067 7.10005 5.25361 6.69711 5.25361 6.20005C5.25361 5.70299 4.85067 5.30005 4.35361 5.30005C3.85656 5.30005 3.45361 5.70299 3.45361 6.20005C3.45361 6.69711 3.85656 7.10005 4.35361 7.10005ZM5.25361 12.0001C5.25361 12.4972 4.85067 12.9001 4.35361 12.9001C3.85656 12.9001 3.45361 12.4972 3.45361 12.0001C3.45361 11.503 3.85656 11.1001 4.35361 11.1001C4.85067 11.1001 5.25361 11.503 5.25361 12.0001ZM8.1687 11C7.61642 11 7.1687 11.4477 7.1687 12C7.1687 12.5523 7.61642 13 8.1687 13H19.5461C20.0984 13 20.5461 12.5523 20.5461 12C20.5461 11.4477 20.0984 11 19.5461 11H8.1687ZM5.25361 17.8001C5.25361 18.2972 4.85067 18.7001 4.35361 18.7001C3.85656 18.7001 3.45361 18.2972 3.45361 17.8001C3.45361 17.3031 3.85656 16.9001 4.35361 16.9001C4.85067 16.9001 5.25361 17.3031 5.25361 17.8001ZM8.1687 16.8C7.61642 16.8 7.1687 17.2478 7.1687 17.8C7.1687 18.3523 7.61642 18.8 8.1687 18.8H19.5461C20.0984 18.8 20.5461 18.3523 20.5461 17.8C20.5461 17.2478 20.0984 16.8 19.5461 16.8H8.1687Z" 203 fill="currentColor" 204 /> 205 + </svg> 206 + ); 207 + }; 208 + 209 + export const ListOrderedSmall = (props: Props) => { 210 + return ( 211 + <svg 212 + width="24" 213 + height="24" 214 + viewBox="0 0 24 24" 215 + fill="none" 216 + xmlns="http://www.w3.org/2000/svg" 217 + {...props} 218 + > 219 + {/* Horizontal lines */} 220 + <path 221 + d="M9 6H20M9 12H20M9 18H20" 222 + stroke="currentColor" 223 + strokeWidth="1.5" 224 + strokeLinecap="round" 225 + /> 226 + {/* Numbers 1, 2, 3 */} 227 + <text 228 + x="4.5" 229 + y="7.5" 230 + fontSize="7" 231 + fill="currentColor" 232 + fontFamily="system-ui, -apple-system, sans-serif" 233 + textAnchor="middle" 234 + > 235 + 1. 236 + </text> 237 + <text 238 + x="4.5" 239 + y="13.5" 240 + fontSize="7" 241 + fill="currentColor" 242 + fontFamily="system-ui, -apple-system, sans-serif" 243 + textAnchor="middle" 244 + > 245 + 2. 246 + </text> 247 + <text 248 + x="4.5" 249 + y="19.5" 250 + fontSize="7" 251 + fill="currentColor" 252 + fontFamily="system-ui, -apple-system, sans-serif" 253 + textAnchor="middle" 254 + > 255 + 3. 256 + </text> 257 </svg> 258 ); 259 };
+2
lexicons/api/index.ts
··· 32 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 33 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 34 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 35 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 36 import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 37 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' ··· 79 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 80 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 81 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 82 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 83 export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 84 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
··· 32 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 33 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 34 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 35 + import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 36 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 37 import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 38 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' ··· 80 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 81 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 82 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 83 + export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 84 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 85 export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 86 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
+64
lexicons/api/lexicons.ts
··· 1207 }, 1208 }, 1209 }, 1210 PubLeafletBlocksPage: { 1211 lexicon: 1, 1212 id: 'pub.leaflet.blocks.page', ··· 1295 }, 1296 children: { 1297 type: 'array', 1298 items: { 1299 type: 'ref', 1300 ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 1301 }, 1302 }, 1303 }, 1304 }, ··· 1582 'lex:pub.leaflet.blocks.header', 1583 'lex:pub.leaflet.blocks.image', 1584 'lex:pub.leaflet.blocks.unorderedList', 1585 'lex:pub.leaflet.blocks.website', 1586 'lex:pub.leaflet.blocks.math', 1587 'lex:pub.leaflet.blocks.code', ··· 1683 'lex:pub.leaflet.blocks.header', 1684 'lex:pub.leaflet.blocks.image', 1685 'lex:pub.leaflet.blocks.unorderedList', 1686 'lex:pub.leaflet.blocks.website', 1687 'lex:pub.leaflet.blocks.math', 1688 'lex:pub.leaflet.blocks.code', ··· 2454 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 2455 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 2456 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 2457 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2458 PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2459 PubLeafletBlocksText: 'pub.leaflet.blocks.text',
··· 1207 }, 1208 }, 1209 }, 1210 + PubLeafletBlocksOrderedList: { 1211 + lexicon: 1, 1212 + id: 'pub.leaflet.blocks.orderedList', 1213 + defs: { 1214 + main: { 1215 + type: 'object', 1216 + required: ['children'], 1217 + properties: { 1218 + startIndex: { 1219 + type: 'integer', 1220 + description: 1221 + 'The starting number for this ordered list. Defaults to 1 if not specified.', 1222 + }, 1223 + children: { 1224 + type: 'array', 1225 + items: { 1226 + type: 'ref', 1227 + ref: 'lex:pub.leaflet.blocks.orderedList#listItem', 1228 + }, 1229 + }, 1230 + }, 1231 + }, 1232 + listItem: { 1233 + type: 'object', 1234 + required: ['content'], 1235 + properties: { 1236 + content: { 1237 + type: 'union', 1238 + refs: [ 1239 + 'lex:pub.leaflet.blocks.text', 1240 + 'lex:pub.leaflet.blocks.header', 1241 + 'lex:pub.leaflet.blocks.image', 1242 + ], 1243 + }, 1244 + children: { 1245 + type: 'array', 1246 + description: 1247 + 'Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.', 1248 + items: { 1249 + type: 'ref', 1250 + ref: 'lex:pub.leaflet.blocks.orderedList#listItem', 1251 + }, 1252 + }, 1253 + unorderedListChildren: { 1254 + type: 'ref', 1255 + description: 1256 + 'A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.', 1257 + ref: 'lex:pub.leaflet.blocks.unorderedList', 1258 + }, 1259 + }, 1260 + }, 1261 + }, 1262 + }, 1263 PubLeafletBlocksPage: { 1264 lexicon: 1, 1265 id: 'pub.leaflet.blocks.page', ··· 1348 }, 1349 children: { 1350 type: 'array', 1351 + description: 1352 + 'Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.', 1353 items: { 1354 type: 'ref', 1355 ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 1356 }, 1357 + }, 1358 + orderedListChildren: { 1359 + type: 'ref', 1360 + description: 1361 + 'Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.', 1362 + ref: 'lex:pub.leaflet.blocks.orderedList', 1363 }, 1364 }, 1365 }, ··· 1643 'lex:pub.leaflet.blocks.header', 1644 'lex:pub.leaflet.blocks.image', 1645 'lex:pub.leaflet.blocks.unorderedList', 1646 + 'lex:pub.leaflet.blocks.orderedList', 1647 'lex:pub.leaflet.blocks.website', 1648 'lex:pub.leaflet.blocks.math', 1649 'lex:pub.leaflet.blocks.code', ··· 1745 'lex:pub.leaflet.blocks.header', 1746 'lex:pub.leaflet.blocks.image', 1747 'lex:pub.leaflet.blocks.unorderedList', 1748 + 'lex:pub.leaflet.blocks.orderedList', 1749 'lex:pub.leaflet.blocks.website', 1750 'lex:pub.leaflet.blocks.math', 1751 'lex:pub.leaflet.blocks.code', ··· 2517 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 2518 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 2519 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 2520 + PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', 2521 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2522 PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2523 PubLeafletBlocksText: 'pub.leaflet.blocks.text',
+58
lexicons/api/types/pub/leaflet/blocks/orderedList.ts
···
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as PubLeafletBlocksText from './text' 13 + import type * as PubLeafletBlocksHeader from './header' 14 + import type * as PubLeafletBlocksImage from './image' 15 + import type * as PubLeafletBlocksUnorderedList from './unorderedList' 16 + 17 + const is$typed = _is$typed, 18 + validate = _validate 19 + const id = 'pub.leaflet.blocks.orderedList' 20 + 21 + export interface Main { 22 + $type?: 'pub.leaflet.blocks.orderedList' 23 + /** The starting number for this ordered list. Defaults to 1 if not specified. */ 24 + startIndex?: number 25 + children: ListItem[] 26 + } 27 + 28 + const hashMain = 'main' 29 + 30 + export function isMain<V>(v: V) { 31 + return is$typed(v, id, hashMain) 32 + } 33 + 34 + export function validateMain<V>(v: V) { 35 + return validate<Main & V>(v, id, hashMain) 36 + } 37 + 38 + export interface ListItem { 39 + $type?: 'pub.leaflet.blocks.orderedList#listItem' 40 + content: 41 + | $Typed<PubLeafletBlocksText.Main> 42 + | $Typed<PubLeafletBlocksHeader.Main> 43 + | $Typed<PubLeafletBlocksImage.Main> 44 + | { $type: string } 45 + /** Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence. */ 46 + children?: ListItem[] 47 + unorderedListChildren?: PubLeafletBlocksUnorderedList.Main 48 + } 49 + 50 + const hashListItem = 'listItem' 51 + 52 + export function isListItem<V>(v: V) { 53 + return is$typed(v, id, hashListItem) 54 + } 55 + 56 + export function validateListItem<V>(v: V) { 57 + return validate<ListItem & V>(v, id, hashListItem) 58 + }
+3
lexicons/api/types/pub/leaflet/blocks/unorderedList.ts
··· 12 import type * as PubLeafletBlocksText from './text' 13 import type * as PubLeafletBlocksHeader from './header' 14 import type * as PubLeafletBlocksImage from './image' 15 16 const is$typed = _is$typed, 17 validate = _validate ··· 39 | $Typed<PubLeafletBlocksHeader.Main> 40 | $Typed<PubLeafletBlocksImage.Main> 41 | { $type: string } 42 children?: ListItem[] 43 } 44 45 const hashListItem = 'listItem'
··· 12 import type * as PubLeafletBlocksText from './text' 13 import type * as PubLeafletBlocksHeader from './header' 14 import type * as PubLeafletBlocksImage from './image' 15 + import type * as PubLeafletBlocksOrderedList from './orderedList' 16 17 const is$typed = _is$typed, 18 validate = _validate ··· 40 | $Typed<PubLeafletBlocksHeader.Main> 41 | $Typed<PubLeafletBlocksImage.Main> 42 | { $type: string } 43 + /** Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. */ 44 children?: ListItem[] 45 + orderedListChildren?: PubLeafletBlocksOrderedList.Main 46 } 47 48 const hashListItem = 'listItem'
+2
lexicons/api/types/pub/leaflet/pages/canvas.ts
··· 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 import type * as PubLeafletBlocksMath from '../blocks/math' 20 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 53 | $Typed<PubLeafletBlocksHeader.Main> 54 | $Typed<PubLeafletBlocksImage.Main> 55 | $Typed<PubLeafletBlocksUnorderedList.Main> 56 | $Typed<PubLeafletBlocksWebsite.Main> 57 | $Typed<PubLeafletBlocksMath.Main> 58 | $Typed<PubLeafletBlocksCode.Main>
··· 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' 19 import type * as PubLeafletBlocksWebsite from '../blocks/website' 20 import type * as PubLeafletBlocksMath from '../blocks/math' 21 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 54 | $Typed<PubLeafletBlocksHeader.Main> 55 | $Typed<PubLeafletBlocksImage.Main> 56 | $Typed<PubLeafletBlocksUnorderedList.Main> 57 + | $Typed<PubLeafletBlocksOrderedList.Main> 58 | $Typed<PubLeafletBlocksWebsite.Main> 59 | $Typed<PubLeafletBlocksMath.Main> 60 | $Typed<PubLeafletBlocksCode.Main>
+2
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 import type * as PubLeafletBlocksMath from '../blocks/math' 20 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 53 | $Typed<PubLeafletBlocksHeader.Main> 54 | $Typed<PubLeafletBlocksImage.Main> 55 | $Typed<PubLeafletBlocksUnorderedList.Main> 56 | $Typed<PubLeafletBlocksWebsite.Main> 57 | $Typed<PubLeafletBlocksMath.Main> 58 | $Typed<PubLeafletBlocksCode.Main>
··· 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' 19 import type * as PubLeafletBlocksWebsite from '../blocks/website' 20 import type * as PubLeafletBlocksMath from '../blocks/math' 21 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 54 | $Typed<PubLeafletBlocksHeader.Main> 55 | $Typed<PubLeafletBlocksImage.Main> 56 | $Typed<PubLeafletBlocksUnorderedList.Main> 57 + | $Typed<PubLeafletBlocksOrderedList.Main> 58 | $Typed<PubLeafletBlocksWebsite.Main> 59 | $Typed<PubLeafletBlocksMath.Main> 60 | $Typed<PubLeafletBlocksCode.Main>
+54
lexicons/pub/leaflet/blocks/orderedList.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.orderedList", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "children" 9 + ], 10 + "properties": { 11 + "startIndex": { 12 + "type": "integer", 13 + "description": "The starting number for this ordered list. Defaults to 1 if not specified." 14 + }, 15 + "children": { 16 + "type": "array", 17 + "items": { 18 + "type": "ref", 19 + "ref": "#listItem" 20 + } 21 + } 22 + } 23 + }, 24 + "listItem": { 25 + "type": "object", 26 + "required": [ 27 + "content" 28 + ], 29 + "properties": { 30 + "content": { 31 + "type": "union", 32 + "refs": [ 33 + "pub.leaflet.blocks.text", 34 + "pub.leaflet.blocks.header", 35 + "pub.leaflet.blocks.image" 36 + ] 37 + }, 38 + "children": { 39 + "type": "array", 40 + "description": "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", 41 + "items": { 42 + "type": "ref", 43 + "ref": "#listItem" 44 + } 45 + }, 46 + "unorderedListChildren": { 47 + "type": "ref", 48 + "description": "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", 49 + "ref": "pub.leaflet.blocks.unorderedList" 50 + } 51 + } 52 + } 53 + } 54 + }
+6
lexicons/pub/leaflet/blocks/unorderedList.json
··· 33 }, 34 "children": { 35 "type": "array", 36 "items": { 37 "type": "ref", 38 "ref": "#listItem" 39 } 40 } 41 } 42 }
··· 33 }, 34 "children": { 35 "type": "array", 36 + "description": "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", 37 "items": { 38 "type": "ref", 39 "ref": "#listItem" 40 } 41 + }, 42 + "orderedListChildren": { 43 + "type": "ref", 44 + "description": "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", 45 + "ref": "pub.leaflet.blocks.orderedList" 46 } 47 } 48 }
+1
lexicons/pub/leaflet/pages/canvas.json
··· 38 "pub.leaflet.blocks.header", 39 "pub.leaflet.blocks.image", 40 "pub.leaflet.blocks.unorderedList", 41 "pub.leaflet.blocks.website", 42 "pub.leaflet.blocks.math", 43 "pub.leaflet.blocks.code",
··· 38 "pub.leaflet.blocks.header", 39 "pub.leaflet.blocks.image", 40 "pub.leaflet.blocks.unorderedList", 41 + "pub.leaflet.blocks.orderedList", 42 "pub.leaflet.blocks.website", 43 "pub.leaflet.blocks.math", 44 "pub.leaflet.blocks.code",
+1
lexicons/pub/leaflet/pages/linearDocument.json
··· 35 "pub.leaflet.blocks.header", 36 "pub.leaflet.blocks.image", 37 "pub.leaflet.blocks.unorderedList", 38 "pub.leaflet.blocks.website", 39 "pub.leaflet.blocks.math", 40 "pub.leaflet.blocks.code",
··· 35 "pub.leaflet.blocks.header", 36 "pub.leaflet.blocks.image", 37 "pub.leaflet.blocks.unorderedList", 38 + "pub.leaflet.blocks.orderedList", 39 "pub.leaflet.blocks.website", 40 "pub.leaflet.blocks.math", 41 "pub.leaflet.blocks.code",
+25 -3
lexicons/src/blocks.ts
··· 201 type: "object", 202 required: ["children"], 203 properties: { 204 - startIndex: { type: "integer" }, 205 children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 206 }, 207 }, ··· 217 PubLeafletBlocksImage, 218 ].map((l) => l.id), 219 }, 220 - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 221 }, 222 }, 223 }, ··· 246 PubLeafletBlocksImage, 247 ].map((l) => l.id), 248 }, 249 - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 250 }, 251 }, 252 }, ··· 303 PubLeafletBlocksHeader, 304 PubLeafletBlocksImage, 305 PubLeafletBlocksUnorderedList, 306 PubLeafletBlocksWebsite, 307 PubLeafletBlocksMath, 308 PubLeafletBlocksCode,
··· 201 type: "object", 202 required: ["children"], 203 properties: { 204 + startIndex: { 205 + type: "integer", 206 + description: "The starting number for this ordered list. Defaults to 1 if not specified.", 207 + }, 208 children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 209 }, 210 }, ··· 220 PubLeafletBlocksImage, 221 ].map((l) => l.id), 222 }, 223 + children: { 224 + type: "array", 225 + description: "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", 226 + items: { type: "ref", ref: "#listItem" }, 227 + }, 228 + unorderedListChildren: { 229 + type: "ref", 230 + description: "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", 231 + ref: "pub.leaflet.blocks.unorderedList", 232 + }, 233 }, 234 }, 235 }, ··· 258 PubLeafletBlocksImage, 259 ].map((l) => l.id), 260 }, 261 + children: { 262 + type: "array", 263 + description: "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", 264 + items: { type: "ref", ref: "#listItem" }, 265 + }, 266 + orderedListChildren: { 267 + type: "ref", 268 + description: "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", 269 + ref: "pub.leaflet.blocks.orderedList", 270 + }, 271 }, 272 }, 273 }, ··· 324 PubLeafletBlocksHeader, 325 PubLeafletBlocksImage, 326 PubLeafletBlocksUnorderedList, 327 + PubLeafletBlocksOrderedList, 328 PubLeafletBlocksWebsite, 329 PubLeafletBlocksMath, 330 PubLeafletBlocksCode,
+2 -134
src/hooks/queries/useBlocks.ts
··· 1 - import { Block } from "components/Blocks/Block"; 2 import { useMemo } from "react"; 3 - import { ReadTransaction } from "replicache"; 4 import { useSubscribe } from "src/replicache/useSubscribe"; 5 - import { Fact, useReplicache } from "src/replicache"; 6 import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 7 8 export const useBlocks = (entityID: string | null) => { 9 let rep = useReplicache(); ··· 69 }); 70 }; 71 72 - export const getBlocksWithType = async ( 73 - tx: ReadTransaction, 74 - entityID: string, 75 - ) => { 76 - let initialized = await tx.get("initialized"); 77 - if (!initialized) return null; 78 - let scan = scanIndex(tx); 79 - let blocks = await scan.eav(entityID, "card/block"); 80 - 81 - return ( 82 - await Promise.all( 83 - blocks 84 - .sort((a, b) => { 85 - if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; 86 - return a.data.position > b.data.position ? 1 : -1; 87 - }) 88 - .map(async (b) => { 89 - let type = (await scan.eav(b.data.value, "block/type"))[0]; 90 - let isList = await scan.eav(b.data.value, "block/is-list"); 91 - if (!type) return null; 92 - if (isList[0]?.data.value) { 93 - const getChildren = async ( 94 - root: Fact<"card/block">, 95 - parent: string, 96 - depth: number, 97 - path: { depth: number; entity: string }[], 98 - ): Promise<Block[]> => { 99 - let children = ( 100 - await scan.eav(root.data.value, "card/block") 101 - ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 102 - let type = (await scan.eav(root.data.value, "block/type"))[0]; 103 - let checklist = await scan.eav( 104 - root.data.value, 105 - "block/check-list", 106 - ); 107 - if (!type) return []; 108 - let newPath = [...path, { entity: root.data.value, depth }]; 109 - let childBlocks = await Promise.all( 110 - children.map((c) => 111 - getChildren(c, root.data.value, depth + 1, newPath), 112 - ), 113 - ); 114 - return [ 115 - { 116 - ...root.data, 117 - factID: root.id, 118 - type: type.data.value, 119 - parent: b.entity, 120 - listData: { 121 - depth: depth, 122 - parent, 123 - path: newPath, 124 - checklist: !!checklist[0], 125 - }, 126 - }, 127 - ...childBlocks.flat(), 128 - ]; 129 - }; 130 - return getChildren(b, b.entity, 1, []); 131 - } 132 - return [ 133 - { 134 - ...b.data, 135 - factID: b.id, 136 - type: type.data.value, 137 - parent: b.entity, 138 - }, 139 - ] as Block[]; 140 - }), 141 - ) 142 - ) 143 - .flat() 144 - .filter((f) => f !== null); 145 - }; 146 - 147 - export const getBlocksWithTypeLocal = ( 148 - initialFacts: Fact<any>[], 149 - entityID: string, 150 - ) => { 151 - let scan = scanIndexLocal(initialFacts); 152 - let blocks = scan.eav(entityID, "card/block"); 153 - return blocks 154 - .sort((a, b) => { 155 - if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; 156 - return a.data.position > b.data.position ? 1 : -1; 157 - }) 158 - .map((b) => { 159 - let type = scan.eav(b.data.value, "block/type")[0]; 160 - let isList = scan.eav(b.data.value, "block/is-list"); 161 - if (!type) return null; 162 - if (isList[0]?.data.value) { 163 - const getChildren = ( 164 - root: Fact<"card/block">, 165 - parent: string, 166 - depth: number, 167 - path: { depth: number; entity: string }[], 168 - ): Block[] => { 169 - let children = scan 170 - .eav(root.data.value, "card/block") 171 - .sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 172 - let type = scan.eav(root.data.value, "block/type")[0]; 173 - if (!type) return []; 174 - let newPath = [...path, { entity: root.data.value, depth }]; 175 - let childBlocks = children.map((c) => 176 - getChildren(c, root.data.value, depth + 1, newPath), 177 - ); 178 - return [ 179 - { 180 - ...root.data, 181 - factID: root.id, 182 - type: type.data.value, 183 - parent: b.entity, 184 - listData: { depth: depth, parent, path: newPath }, 185 - }, 186 - ...childBlocks.flat(), 187 - ]; 188 - }; 189 - return getChildren(b, b.entity, 1, []); 190 - } 191 - return [ 192 - { 193 - ...b.data, 194 - factID: b.id, 195 - type: type.data.value, 196 - parent: b.entity, 197 - }, 198 - ] as Block[]; 199 - }) 200 - .flat() 201 - .filter((f) => f !== null); 202 - };
··· 1 import { useMemo } from "react"; 2 import { useSubscribe } from "src/replicache/useSubscribe"; 3 + import { useReplicache } from "src/replicache"; 4 import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 5 + import { getBlocksWithType, getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 6 7 export const useBlocks = (entityID: string | null) => { 8 let rep = useReplicache(); ··· 68 }); 69 }; 70
+12
src/replicache/attributes.ts
··· 99 type: "string", 100 cardinality: "one", 101 }, 102 } as const; 103 104 const MailboxAttributes = { ··· 358 "canvas-pattern-union": { 359 type: "canvas-pattern-union"; 360 value: "dot" | "grid" | "plain"; 361 }; 362 color: { type: "color"; value: string }; 363 }[(typeof Attributes)[A]["type"]];
··· 99 type: "string", 100 cardinality: "one", 101 }, 102 + "block/list-style": { 103 + type: "list-style-union", 104 + cardinality: "one", 105 + }, 106 + "block/list-number": { 107 + type: "number", 108 + cardinality: "one", 109 + }, 110 } as const; 111 112 const MailboxAttributes = { ··· 366 "canvas-pattern-union": { 367 type: "canvas-pattern-union"; 368 value: "dot" | "grid" | "plain"; 369 + }; 370 + "list-style-union": { 371 + type: "list-style-union"; 372 + value: "ordered" | "unordered"; 373 }; 374 color: { type: "color"; value: string }; 375 }[(typeof Attributes)[A]["type"]];
+175
src/replicache/getBlocks.ts
···
··· 1 + import { Block } from "components/Blocks/Block"; 2 + import { ReadTransaction } from "replicache"; 3 + import { Fact } from "src/replicache"; 4 + import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 5 + 6 + function computeDisplayNumbers(blocks: Block[]): void { 7 + let counters = new Map<string, number>(); 8 + for (let block of blocks) { 9 + if (!block.listData) { 10 + counters.clear(); 11 + continue; 12 + } 13 + if (block.listData.listStyle !== "ordered") continue; 14 + let parent = block.listData.parent; 15 + if (block.listData.listStart !== undefined) { 16 + counters.set(parent, block.listData.listStart); 17 + } else if (!counters.has(parent)) { 18 + counters.set(parent, 1); 19 + } 20 + block.listData.displayNumber = counters.get(parent)!; 21 + counters.set(parent, counters.get(parent)! + 1); 22 + } 23 + } 24 + 25 + export const getBlocksWithType = async ( 26 + tx: ReadTransaction, 27 + entityID: string, 28 + ) => { 29 + let initialized = await tx.get("initialized"); 30 + if (!initialized) return null; 31 + let scan = scanIndex(tx); 32 + let blocks = await scan.eav(entityID, "card/block"); 33 + 34 + let result = ( 35 + await Promise.all( 36 + blocks 37 + .sort((a, b) => { 38 + if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; 39 + return a.data.position > b.data.position ? 1 : -1; 40 + }) 41 + .map(async (b) => { 42 + let type = (await scan.eav(b.data.value, "block/type"))[0]; 43 + let isList = await scan.eav(b.data.value, "block/is-list"); 44 + if (!type) return null; 45 + // All lists use recursive structure 46 + if (isList[0]?.data.value) { 47 + const getChildren = async ( 48 + root: Fact<"card/block">, 49 + parent: string, 50 + depth: number, 51 + path: { depth: number; entity: string }[], 52 + ): Promise<Block[]> => { 53 + let children = ( 54 + await scan.eav(root.data.value, "card/block") 55 + ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 56 + let type = (await scan.eav(root.data.value, "block/type"))[0]; 57 + let checklist = await scan.eav( 58 + root.data.value, 59 + "block/check-list", 60 + ); 61 + let listStyle = (await scan.eav(root.data.value, "block/list-style"))[0]; 62 + let listNumber = (await scan.eav(root.data.value, "block/list-number"))[0]; 63 + if (!type) return []; 64 + let newPath = [...path, { entity: root.data.value, depth }]; 65 + let childBlocks = await Promise.all( 66 + children.map((c) => 67 + getChildren(c, root.data.value, depth + 1, newPath), 68 + ), 69 + ); 70 + return [ 71 + { 72 + ...root.data, 73 + factID: root.id, 74 + type: type.data.value, 75 + parent: b.entity, 76 + listData: { 77 + depth: depth, 78 + parent, 79 + path: newPath, 80 + checklist: !!checklist[0], 81 + listStyle: listStyle?.data.value, 82 + listStart: listNumber?.data.value, 83 + }, 84 + }, 85 + ...childBlocks.flat(), 86 + ]; 87 + }; 88 + return getChildren(b, b.entity, 1, []); 89 + } 90 + return [ 91 + { 92 + ...b.data, 93 + factID: b.id, 94 + type: type.data.value, 95 + parent: b.entity, 96 + }, 97 + ] as Block[]; 98 + }), 99 + ) 100 + ) 101 + .flat() 102 + .filter((f) => f !== null); 103 + 104 + computeDisplayNumbers(result); 105 + return result; 106 + }; 107 + 108 + export const getBlocksWithTypeLocal = ( 109 + initialFacts: Fact<any>[], 110 + entityID: string, 111 + ) => { 112 + let scan = scanIndexLocal(initialFacts); 113 + let blocks = scan.eav(entityID, "card/block"); 114 + let result = blocks 115 + .sort((a, b) => { 116 + if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; 117 + return a.data.position > b.data.position ? 1 : -1; 118 + }) 119 + .map((b) => { 120 + let type = scan.eav(b.data.value, "block/type")[0]; 121 + let isList = scan.eav(b.data.value, "block/is-list"); 122 + if (!type) return null; 123 + // All lists use recursive structure 124 + if (isList[0]?.data.value) { 125 + const getChildren = ( 126 + root: Fact<"card/block">, 127 + parent: string, 128 + depth: number, 129 + path: { depth: number; entity: string }[], 130 + ): Block[] => { 131 + let children = scan 132 + .eav(root.data.value, "card/block") 133 + .sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 134 + let type = scan.eav(root.data.value, "block/type")[0]; 135 + let listStyle = scan.eav(root.data.value, "block/list-style")[0]; 136 + let listNumber = scan.eav(root.data.value, "block/list-number")[0]; 137 + if (!type) return []; 138 + let newPath = [...path, { entity: root.data.value, depth }]; 139 + let childBlocks = children.map((c) => 140 + getChildren(c, root.data.value, depth + 1, newPath), 141 + ); 142 + return [ 143 + { 144 + ...root.data, 145 + factID: root.id, 146 + type: type.data.value, 147 + parent: b.entity, 148 + listData: { 149 + depth: depth, 150 + parent, 151 + path: newPath, 152 + listStyle: listStyle?.data.value, 153 + listStart: listNumber?.data.value, 154 + }, 155 + }, 156 + ...childBlocks.flat(), 157 + ]; 158 + }; 159 + return getChildren(b, b.entity, 1, []); 160 + } 161 + return [ 162 + { 163 + ...b.data, 164 + factID: b.id, 165 + type: type.data.value, 166 + parent: b.entity, 167 + }, 168 + ] as Block[]; 169 + }) 170 + .flat() 171 + .filter((f) => f !== null); 172 + 173 + computeDisplayNumbers(result); 174 + return result; 175 + };
+6 -1
src/replicache/mutations.ts
··· 212 newParent: string; 213 after: string; 214 block: string; 215 }> = async (args, ctx) => { 216 //we should be able to get normal siblings here as we care only about one level 217 let newSiblings = ( ··· 225 (f) => f.data.value === args.block, 226 ); 227 if (currentFactIndex === -1) return; 228 - let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1); 229 let currentChildren = ( 230 await ctx.scanIndex.eav(args.block, "card/block") 231 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
··· 212 newParent: string; 213 after: string; 214 block: string; 215 + excludeFromSiblings?: string[]; 216 }> = async (args, ctx) => { 217 //we should be able to get normal siblings here as we care only about one level 218 let newSiblings = ( ··· 226 (f) => f.data.value === args.block, 227 ); 228 if (currentFactIndex === -1) return; 229 + // Filter out blocks that are being processed separately (e.g., in multi-select outdent) 230 + let excludeSet = new Set(args.excludeFromSiblings || []); 231 + let currentSiblingsAfter = currentSiblings 232 + .slice(currentFactIndex + 1) 233 + .filter((sib) => !excludeSet.has(sib.data.value)); 234 let currentChildren = ( 235 await ctx.scanIndex.eav(args.block, "card/block") 236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
+5 -4
src/utils/deleteBlock.ts
··· 2 import { ReplicacheMutators } from "src/replicache"; 3 import { useUIState } from "src/useUIState"; 4 import { scanIndex } from "src/replicache/utils"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 import { UndoManager } from "src/undoManager"; 8 ··· 13 ) { 14 // get what pagess we need to close as a result of deleting this block 15 let pagesToClose = [] as string[]; 16 for (let entity of entities) { 17 let [type] = await rep.query((tx) => 18 scanIndex(tx).eav(entity, "block/type"), ··· 34 } 35 } 36 37 - // figure out what to focus 38 let focusedBlock = useUIState.getState().focusedEntity; 39 let parent = 40 focusedBlock?.entityType === "page" ··· 110 111 // close the pages 112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 - undoManager && undoManager.startGroup(); 114 115 - // delete the blocks 116 await Promise.all( 117 entities.map((entity) => 118 rep?.mutate.removeBlock({ ··· 120 }), 121 ), 122 ); 123 undoManager && undoManager.endGroup(); 124 }
··· 2 import { ReplicacheMutators } from "src/replicache"; 3 import { useUIState } from "src/useUIState"; 4 import { scanIndex } from "src/replicache/utils"; 5 + import { getBlocksWithType } from "src/replicache/getBlocks"; 6 import { focusBlock } from "src/utils/focusBlock"; 7 import { UndoManager } from "src/undoManager"; 8 ··· 13 ) { 14 // get what pagess we need to close as a result of deleting this block 15 let pagesToClose = [] as string[]; 16 + 17 for (let entity of entities) { 18 let [type] = await rep.query((tx) => 19 scanIndex(tx).eav(entity, "block/type"), ··· 35 } 36 } 37 38 + // the next and previous blocks in the block list 39 + // if the focused thing is a page and not a block, return 40 let focusedBlock = useUIState.getState().focusedEntity; 41 let parent = 42 focusedBlock?.entityType === "page" ··· 112 113 // close the pages 114 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 115 116 await Promise.all( 117 entities.map((entity) => 118 rep?.mutate.removeBlock({ ··· 120 }), 121 ), 122 ); 123 + 124 undoManager && undoManager.endGroup(); 125 }
+13 -4
src/utils/getBlocksAsHTML.tsx
··· 16 let parsed = parseBlocksToList(selectedBlocks); 17 for (let pb of parsed) { 18 if (pb.type === "block") result.push(await renderBlock(pb.block, tx)); 19 - else 20 result.push( 21 - `<ul>${( 22 await Promise.all( 23 pb.children.map(async (c) => await renderList(c, tx)), 24 ) 25 ).join("\n")} 26 - </ul>`, 27 ); 28 } 29 return result; 30 }); ··· 36 await Promise.all(l.children.map(async (c) => await renderList(c, tx))) 37 ).join("\n"); 38 let [checked] = await scanIndex(tx).eav(l.block.value, "block/check-list"); 39 return `<li ${checked ? `data-checked=${checked.data.value}` : ""}>${await renderBlock(l.block, tx)} ${ 40 l.children.length > 0 41 ? ` 42 - <ul>${children}</ul> 43 ` 44 : "" 45 }</li>`;
··· 16 let parsed = parseBlocksToList(selectedBlocks); 17 for (let pb of parsed) { 18 if (pb.type === "block") result.push(await renderBlock(pb.block, tx)); 19 + else { 20 + // Check if the first child is an ordered list 21 + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; 22 + let tag = isOrdered ? "ol" : "ul"; 23 result.push( 24 + `<${tag}>${( 25 await Promise.all( 26 pb.children.map(async (c) => await renderList(c, tx)), 27 ) 28 ).join("\n")} 29 + </${tag}>`, 30 ); 31 + } 32 } 33 return result; 34 }); ··· 40 await Promise.all(l.children.map(async (c) => await renderList(c, tx))) 41 ).join("\n"); 42 let [checked] = await scanIndex(tx).eav(l.block.value, "block/check-list"); 43 + 44 + // Check if nested children are ordered or unordered 45 + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; 46 + let tag = isOrdered ? "ol" : "ul"; 47 + 48 return `<li ${checked ? `data-checked=${checked.data.value}` : ""}>${await renderBlock(l.block, tx)} ${ 49 l.children.length > 0 50 ? ` 51 + <${tag}>${children}</${tag}> 52 ` 53 : "" 54 }</li>`;
+110 -27
src/utils/list-operations.ts
··· 1 import { Block } from "components/Blocks/Block"; 2 import { Replicache } from "replicache"; 3 import type { ReplicacheMutators } from "src/replicache"; 4 - import { useUIState } from "src/useUIState"; 5 import { v7 } from "uuid"; 6 7 - export function indent( 8 block: Block, 9 previousBlock?: Block, 10 rep?: Replicache<ReplicacheMutators> | null, 11 - ) { 12 - if (!block.listData) return false; 13 - if (!previousBlock?.listData) return false; 14 let depth = block.listData.depth; 15 let newParent = previousBlock.listData.path.find((f) => f.depth === depth); 16 - if (!newParent) return false; 17 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 18 - useUIState.getState().toggleFold(newParent.entity); 19 rep?.mutate.retractFact({ factID: block.factID }); 20 rep?.mutate.addLastBlock({ 21 parent: newParent.entity, 22 factID: v7(), 23 entity: block.value, 24 }); 25 - return true; 26 } 27 28 export function outdentFull( ··· 38 data: { type: "boolean", value: false }, 39 }); 40 41 - // find the next block that is a level 1 list item or not a list item. 42 - // If there are none or this block is a level 1 list item, we don't need to move anything 43 - 44 let after = block.listData?.path.find((f) => f.depth === 1)?.entity; 45 46 - // move this block to be after that block 47 after && 48 after !== block.value && 49 rep?.mutate.moveBlock({ ··· 61 }); 62 } 63 64 - export function outdent( 65 block: Block, 66 - previousBlock: Block | null, 67 rep?: Replicache<ReplicacheMutators> | null, 68 - ) { 69 - if (!block.listData) return false; 70 let listData = block.listData; 71 if (listData.depth === 1) { 72 - rep?.mutate.assertFact({ 73 entity: block.value, 74 attribute: "block/is-list", 75 data: { type: "boolean", value: false }, 76 }); 77 - rep?.mutate.moveChildren({ 78 oldParent: block.value, 79 newParent: block.parent, 80 after: block.value, 81 }); 82 } else { 83 - if (!previousBlock || !previousBlock.listData) return false; 84 - let after = previousBlock.listData.path.find( 85 (f) => f.depth === listData.depth - 1, 86 )?.entity; 87 - if (!after) return false; 88 let parent: string | undefined = undefined; 89 if (listData.depth === 2) { 90 parent = block.parent; 91 } else { 92 - parent = previousBlock.listData.path.find( 93 (f) => f.depth === listData.depth - 2, 94 )?.entity; 95 } 96 - if (!parent) return false; 97 - if (useUIState.getState().foldedBlocks.includes(parent)) 98 - useUIState.getState().toggleFold(parent); 99 - rep?.mutate.outdentBlock({ 100 block: block.value, 101 newParent: parent, 102 oldParent: listData.parent, 103 after, 104 }); 105 } 106 }
··· 1 import { Block } from "components/Blocks/Block"; 2 import { Replicache } from "replicache"; 3 import type { ReplicacheMutators } from "src/replicache"; 4 import { v7 } from "uuid"; 5 6 + export function orderListItems( 7 + block: Block, 8 + rep?: Replicache<ReplicacheMutators> | null, 9 + ) { 10 + if (!block.listData) return; 11 + rep?.mutate.assertFact({ 12 + entity: block.value, 13 + attribute: "block/list-style", 14 + data: { type: "list-style-union", value: "ordered" }, 15 + }); 16 + } 17 + 18 + export function unorderListItems( 19 + block: Block, 20 + rep?: Replicache<ReplicacheMutators> | null, 21 + ) { 22 + if (!block.listData) return; 23 + // Remove list-style attribute to convert back to unordered 24 + rep?.mutate.retractAttribute({ 25 + entity: block.value, 26 + attribute: "block/list-style", 27 + }); 28 + } 29 + 30 + export async function indent( 31 block: Block, 32 previousBlock?: Block, 33 rep?: Replicache<ReplicacheMutators> | null, 34 + foldState?: { 35 + foldedBlocks: string[]; 36 + toggleFold: (entityID: string) => void; 37 + }, 38 + ): Promise<{ success: boolean }> { 39 + if (!block.listData) return { success: false }; 40 + 41 + // All lists use parent/child structure - move to new parent 42 + if (!previousBlock?.listData) return { success: false }; 43 let depth = block.listData.depth; 44 let newParent = previousBlock.listData.path.find((f) => f.depth === depth); 45 + if (!newParent) return { success: false }; 46 + if (foldState && foldState.foldedBlocks.includes(newParent.entity)) 47 + foldState.toggleFold(newParent.entity); 48 rep?.mutate.retractFact({ factID: block.factID }); 49 rep?.mutate.addLastBlock({ 50 parent: newParent.entity, 51 factID: v7(), 52 entity: block.value, 53 }); 54 + 55 + return { success: true }; 56 } 57 58 export function outdentFull( ··· 68 data: { type: "boolean", value: false }, 69 }); 70 71 let after = block.listData?.path.find((f) => f.depth === 1)?.entity; 72 73 after && 74 after !== block.value && 75 rep?.mutate.moveBlock({ ··· 87 }); 88 } 89 90 + export async function outdent( 91 block: Block, 92 + previousBlock?: Block | null, 93 rep?: Replicache<ReplicacheMutators> | null, 94 + foldState?: { 95 + foldedBlocks: string[]; 96 + toggleFold: (entityID: string) => void; 97 + }, 98 + excludeFromSiblings?: string[], 99 + ): Promise<{ success: boolean }> { 100 + if (!block.listData) return { success: false }; 101 let listData = block.listData; 102 + 103 + // All lists use parent/child structure - move blocks between parents 104 if (listData.depth === 1) { 105 + await rep?.mutate.assertFact({ 106 entity: block.value, 107 attribute: "block/is-list", 108 data: { type: "boolean", value: false }, 109 }); 110 + await rep?.mutate.moveChildren({ 111 oldParent: block.value, 112 newParent: block.parent, 113 after: block.value, 114 }); 115 + return { success: true }; 116 } else { 117 + // Use block's own path for ancestry lookups - it always has correct info 118 + // even in multiselect scenarios where previousBlock may be stale 119 + let after = listData.path.find( 120 (f) => f.depth === listData.depth - 1, 121 )?.entity; 122 + if (!after) return { success: false }; 123 let parent: string | undefined = undefined; 124 if (listData.depth === 2) { 125 parent = block.parent; 126 } else { 127 + parent = listData.path.find( 128 (f) => f.depth === listData.depth - 2, 129 )?.entity; 130 } 131 + if (!parent) return { success: false }; 132 + if (foldState && foldState.foldedBlocks.includes(parent)) 133 + foldState.toggleFold(parent); 134 + await rep?.mutate.outdentBlock({ 135 block: block.value, 136 newParent: parent, 137 oldParent: listData.parent, 138 after, 139 + excludeFromSiblings, 140 }); 141 + 142 + return { success: true }; 143 + } 144 + } 145 + 146 + export async function multiSelectOutdent( 147 + sortedSelection: Block[], 148 + siblings: Block[], 149 + rep: Replicache<ReplicacheMutators>, 150 + foldState: { foldedBlocks: string[]; toggleFold: (entityID: string) => void }, 151 + ): Promise<void> { 152 + let pageParent = siblings[0]?.parent; 153 + if (!pageParent) return; 154 + 155 + let selectedSet = new Set(sortedSelection.map((b) => b.value)); 156 + let selectedEntities = sortedSelection.map((b) => b.value); 157 + 158 + // Check if all selected list items are at depth 1 → convert to text 159 + let allAtDepth1 = sortedSelection.every( 160 + (b) => !b.listData || b.listData.depth === 1, 161 + ); 162 + 163 + if (allAtDepth1) { 164 + // Convert depth-1 items to plain text (outdent handles this) 165 + for (let i = siblings.length - 1; i >= 0; i--) { 166 + let block = siblings[i]; 167 + if (!selectedSet.has(block.value)) continue; 168 + if (!block.listData) continue; 169 + await outdent(block, null, rep, foldState, selectedEntities); 170 + } 171 + } else { 172 + // Normal outdent: iterate backward through siblings 173 + for (let i = siblings.length - 1; i >= 0; i--) { 174 + let block = siblings[i]; 175 + if (!selectedSet.has(block.value)) continue; 176 + if (!block.listData) continue; 177 + if (block.listData.depth === 1) continue; 178 + 179 + // Skip if parent is selected AND parent's depth > 1 180 + let parentEntity = block.listData.parent; 181 + if (selectedSet.has(parentEntity)) { 182 + let parentBlock = siblings.find((s) => s.value === parentEntity); 183 + if (parentBlock?.listData && parentBlock.listData.depth > 1) continue; 184 + } 185 + 186 + await outdent(block, null, rep, foldState, selectedEntities); 187 + } 188 } 189 }