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 25 26 26 Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/). 27 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 + 28 103 ## Technical details 29 104 30 105 The stack:
+1 -1
actions/publishToPublication.ts
··· 41 41 import { Json } from "supabase/database.types"; 42 42 import { $Typed, UnicodeString } from "@atproto/api"; 43 43 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 44 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 44 + import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 45 45 import { Lock } from "src/utils/lock"; 46 46 import type { PubLeafletPublication } from "lexicons/api"; 47 47 import {
+1 -1
actions/subscriptions/subscribeToMailboxWithEmail.ts
··· 6 6 import { drizzle } from "drizzle-orm/node-postgres"; 7 7 import { email_subscriptions_to_entity } from "drizzle/schema"; 8 8 import postgres from "postgres"; 9 - import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 9 + import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 10 10 import type { Fact, PermissionToken } from "src/replicache"; 11 11 import type { Attribute } from "src/replicache/attributes"; 12 12 import { Database } from "supabase/database.types";
+73
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 6 6 PubLeafletBlocksImage, 7 7 PubLeafletBlocksText, 8 8 PubLeafletBlocksUnorderedList, 9 + PubLeafletBlocksOrderedList, 9 10 PubLeafletBlocksWebsite, 10 11 PubLeafletDocument, 11 12 PubLeafletPagesLinearDocument, ··· 238 239 </ul> 239 240 ); 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 + } 241 262 case PubLeafletBlocksMath.isMain(b.block): { 242 263 return <StaticMathBlock block={b.block} />; 243 264 } ··· 459 480 </li> 460 481 ); 461 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 26 import { BlueskyPostBlock } from "./BlueskyPostBlock"; 27 27 import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 28 28 import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 29 - import { LockTiny } from "components/Icons/LockTiny"; 30 29 import { MathBlock } from "./MathBlock"; 31 30 import { CodeBlock } from "./CodeBlock"; 32 31 import { HorizontalRule } from "./HorizontalRule"; 33 32 import { deepEquals } from "src/utils/deepEquals"; 34 33 import { isTextBlock } from "src/utils/isTextBlock"; 35 - import { focusPage } from "src/utils/focusPage"; 36 34 import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 35 import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 36 import { Separator } from "components/Layout"; ··· 47 45 type: Fact<"block/type">["data"]["value"]; 48 46 listData?: { 49 47 checklist?: boolean; 48 + listStyle?: "ordered" | "unordered"; 49 + listStart?: number; 50 + displayNumber?: number; 50 51 path: { depth: number; entity: string }[]; 51 52 parent: string; 52 53 depth: number; ··· 192 193 if ( 193 194 prevProps.listData.checklist !== nextProps.listData.checklist || 194 195 prevProps.listData.parent !== nextProps.listData.parent || 195 - prevProps.listData.depth !== nextProps.listData.depth 196 + prevProps.listData.depth !== nextProps.listData.depth || 197 + prevProps.listData.displayNumber !== nextProps.listData.displayNumber || 198 + prevProps.listData.listStyle !== nextProps.listData.listStyle 196 199 ) { 197 200 return false; 198 201 } ··· 495 498 ) => { 496 499 let isMobile = useIsMobile(); 497 500 let checklist = useEntity(props.value, "block/check-list"); 501 + let listStyle = useEntity(props.value, "block/list-style"); 498 502 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 499 503 let children = useEntity(props.value, "card/block"); 500 504 let folded = ··· 504 508 let depth = props.listData?.depth; 505 509 let { permissions } = useEntitySetContext(); 506 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 + }; 507 548 return ( 508 549 <div 509 550 className={`shrink-0 flex justify-end items-center h-3 z-1 ··· 531 572 }} 532 573 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 533 574 > 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 - /> 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 + )} 542 618 </button> 543 619 {checklist && ( 544 620 <button
+23 -2
components/Blocks/BlockCommands.tsx
··· 28 28 } from "components/Icons/BlockTextSmall"; 29 29 import { LinkSmall } from "components/Icons/LinkSmall"; 30 30 import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall"; 31 - import { ListUnorderedSmall } from "components/Toolbar/ListToolbar"; 31 + import { ListUnorderedSmall, ListOrderedSmall } from "components/Toolbar/ListToolbar"; 32 32 import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 33 33 import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 34 34 import { QuoteSmall } from "components/Icons/QuoteSmall"; ··· 151 151 }, 152 152 }, 153 153 { 154 - name: "List", 154 + name: "Unordered List", 155 155 icon: <ListUnorderedSmall />, 156 156 type: "text", 157 157 onSelect: async (rep, props, um) => { ··· 161 161 attribute: "block/is-list", 162 162 data: { value: true, type: "boolean" }, 163 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 + ]); 164 185 clearCommandSearchText(entity); 165 186 }, 166 187 },
+1 -1
components/Blocks/MailboxBlock.tsx
··· 13 13 import { focusPage } from "src/utils/focusPage"; 14 14 import { v7 } from "uuid"; 15 15 import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers"; 16 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 16 + import { getBlocksWithType } from "src/replicache/getBlocks"; 17 17 import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 18 18 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 19 19 import {
+40 -5
components/Blocks/TextBlock/inputRules.ts
··· 154 154 if (propsRef.current.listData) return null; 155 155 let tr = state.tr; 156 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 - }); 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 + } 162 197 return tr; 163 198 }), 164 199
+37 -4
components/Blocks/TextBlock/keymap.ts
··· 21 21 import { v7 } from "uuid"; 22 22 import { scanIndex } from "src/replicache/utils"; 23 23 import { indent, outdent } from "src/utils/list-operations"; 24 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 24 + import { getBlocksWithType } from "src/replicache/getBlocks"; 25 25 import { isTextBlock } from "src/utils/isTextBlock"; 26 26 import { UndoManager } from "src/undoManager"; 27 27 type PropsRef = RefObject< ··· 369 369 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 370 370 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 371 371 ) => 372 - () => { 372 + async () => { 373 373 if (useUIState.getState().selectedBlocks.length > 1) return false; 374 374 if (!repRef.current) return false; 375 375 if (!repRef.current) return false; 376 - outdent(propsRef.current, propsRef.current.previousBlock, repRef.current); 376 + let { foldedBlocks, toggleFold } = useUIState.getState(); 377 + await outdent(propsRef.current, propsRef.current.previousBlock, repRef.current, { 378 + foldedBlocks, 379 + toggleFold, 380 + }); 377 381 return true; 378 382 }; 379 383 ··· 423 427 y: position.data.position.y + box.height, 424 428 }, 425 429 }); 426 - if (propsRef.current.listData) 430 + if (propsRef.current.listData) { 427 431 await repRef.current?.mutate.assertFact({ 428 432 entity: newEntityID, 429 433 attribute: "block/is-list", 430 434 data: { type: "boolean", value: true }, 431 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 + } 432 451 return; 433 452 } 434 453 if (propsRef.current.listData) { ··· 499 518 attribute: "block/is-list", 500 519 data: { type: "boolean", value: true }, 501 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 + } 502 535 let checked = await repRef.current?.query((tx) => 503 536 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 504 537 );
+28 -8
components/Blocks/TextBlock/useHandlePaste.ts
··· 87 87 if ( 88 88 !(children.length === 1 && children[0].tagName === "IMG" && hasImage) 89 89 ) { 90 + const pasteParent = propsRef.current.listData 91 + ? propsRef.current.listData.parent 92 + : propsRef.current.parent; 93 + 90 94 children.forEach((child, index) => { 91 95 createBlockFromHTML(child, { 92 96 undoManager, ··· 95 99 activeBlockProps: propsRef, 96 100 entity_set, 97 101 rep, 98 - parent: propsRef.current.listData 99 - ? propsRef.current.listData.parent 100 - : propsRef.current.parent, 102 + parent: pasteParent, 101 103 getPosition: () => { 102 104 currentPosition = generateKeyBetween( 103 105 currentPosition || null, ··· 169 171 getPosition, 170 172 parent, 171 173 parentType, 174 + listStyle, 175 + depth = 1, 172 176 }: { 173 177 parentType: "canvas" | "doc"; 174 178 parent: string; ··· 179 183 undoManager: UndoManager; 180 184 entity_set: { set: string }; 181 185 getPosition: () => string; 186 + listStyle?: "ordered" | "unordered"; 187 + depth?: number; 182 188 }, 183 189 ) => { 184 190 let type: Fact<"block/type">["data"]["value"] | null; 185 191 let headingLevel: number | null = null; 186 192 let hasChildren = false; 187 193 188 - if (child.tagName === "UL") { 194 + if (child.tagName === "UL" || child.tagName === "OL") { 189 195 let children = Array.from(child.children); 190 196 if (children.length > 0) hasChildren = true; 197 + const childListStyle = child.tagName === "OL" ? "ordered" : "unordered"; 191 198 for (let c of children) { 192 199 createBlockFromHTML(c, { 193 200 first: first && c === children[0], ··· 199 206 getPosition, 200 207 parent, 201 208 parentType, 209 + listStyle: childListStyle, 210 + depth, 202 211 }); 203 212 } 204 213 } ··· 482 491 } 483 492 484 493 if (child.tagName === "LI") { 485 - let ul = Array.from(child.children) 494 + // Look for nested UL or OL 495 + let nestedList = Array.from(child.children) 486 496 .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) 487 - .find((f) => f.tagName === "UL"); 497 + .find((f) => f.tagName === "UL" || f.tagName === "OL"); 488 498 let checked = child.getAttribute("data-checked"); 489 499 if (checked !== null) { 490 500 rep.mutate.assertFact({ ··· 498 508 attribute: "block/is-list", 499 509 data: { type: "boolean", value: true }, 500 510 }); 501 - if (ul) { 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) { 502 520 hasChildren = true; 503 521 let currentPosition: string | null = null; 504 - createBlockFromHTML(ul, { 522 + createBlockFromHTML(nestedList, { 505 523 parentType, 506 524 first: false, 507 525 last: last, ··· 514 532 return currentPosition; 515 533 }, 516 534 parent: entityID, 535 + depth: depth + 1, 517 536 }); 518 537 } 519 538 } ··· 605 624 "H6", 606 625 "LI", 607 626 "UL", 627 + "OL", 608 628 "IMG", 609 629 "A", 610 630 "SPAN",
+7 -4
components/Blocks/useBlockKeyboardHandlers.ts
··· 62 62 return; 63 63 64 64 undoManager.startGroup(); 65 - command?.({ 65 + await command?.({ 66 66 e, 67 67 props, 68 68 rep, ··· 88 88 89 89 const AllowedIfTextBlock = ["Tab"]; 90 90 91 - function Tab({ e, props, rep }: Args) { 91 + async function Tab({ e, props, rep }: Args) { 92 92 // if tab or shift tab, indent or outdent 93 93 if (useUIState.getState().selectedBlocks.length > 1) return false; 94 + let { foldedBlocks, toggleFold } = useUIState.getState(); 94 95 if (e.shiftKey) { 95 96 e.preventDefault(); 96 - outdent(props, props.previousBlock, rep); 97 + await outdent(props, props.previousBlock, rep, { foldedBlocks, toggleFold }); 97 98 } else { 98 99 e.preventDefault(); 99 - if (props.previousBlock) indent(props, props.previousBlock, rep); 100 + if (props.previousBlock) { 101 + await indent(props, props.previousBlock, rep, { foldedBlocks, toggleFold }); 102 + } 100 103 } 101 104 } 102 105
+1 -1
components/Blocks/useBlockMouseHandlers.ts
··· 5 5 import { isTextBlock } from "src/utils/isTextBlock"; 6 6 import { useEntitySetContext } from "components/EntitySetProvider"; 7 7 import { useReplicache } from "src/replicache"; 8 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 8 + import { getBlocksWithType } from "src/replicache/getBlocks"; 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 10 import { useIsMobile } from "src/hooks/isMobile"; 11 11 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
+8 -23
components/SelectionManager/index.tsx
··· 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 7 import { useEditorStates } from "src/state/useEditorState"; 8 8 import { useEntitySetContext } from "../EntitySetProvider"; 9 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 9 + import { getBlocksWithType } from "src/replicache/getBlocks"; 10 + import { indent, outdentFull, multiSelectOutdent } from "src/utils/list-operations"; 11 11 import { addShortcut, Shortcut } from "src/shortcuts"; 12 12 import { elementId } from "src/utils/elementId"; 13 13 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; ··· 486 486 let [sortedSelection, siblings] = await getSortedSelectionBound(); 487 487 if (sortedSelection.length <= 1) return; 488 488 e.preventDefault(); 489 + 489 490 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 - } 491 + let { foldedBlocks, toggleFold } = useUIState.getState(); 492 + await multiSelectOutdent(sortedSelection, siblings, rep, { foldedBlocks, toggleFold }); 510 493 } else { 511 494 for (let i = 0; i < siblings.length; i++) { 512 495 let block = siblings[i]; ··· 526 509 previousBlock = siblings[i - parentoffset]; 527 510 } 528 511 if (!block.listData || !previousBlock.listData) continue; 529 - indent(block, previousBlock, rep); 512 + let { foldedBlocks, toggleFold } = useUIState.getState(); 513 + 514 + await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 530 515 } 531 516 } 532 517 }
+1 -1
components/SelectionManager/selectionState.ts
··· 2 2 import { Replicache } from "replicache"; 3 3 import { ReplicacheMutators } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 5 + import { getBlocksWithType } from "src/replicache/getBlocks"; 6 6 7 7 export const useSelectingMouse = create(() => ({ 8 8 start: null as null | string,
+80 -5
components/Toolbar/ListToolbar.tsx
··· 4 4 import { useUIState } from "src/useUIState"; 5 5 import { metaKey } from "src/utils/metaKey"; 6 6 import { ToolbarButton } from "."; 7 - import { indent, outdent, outdentFull } from "src/utils/list-operations"; 7 + import { indent, outdent, outdentFull, orderListItems, unorderListItems } from "src/utils/list-operations"; 8 8 import { useEffect } from "react"; 9 9 import { Props } from "components/Icons/Props"; 10 10 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; ··· 70 70 71 71 export const ListToolbar = (props: { onClose: () => void }) => { 72 72 let focusedBlock = useUIState((s) => s.focusedEntity); 73 + let foldedBlocks = useUIState((s) => s.foldedBlocks); 74 + let toggleFold = useUIState((s) => s.toggleFold); 73 75 let siblings = useBlocks( 74 76 focusedBlock?.entityType === "block" ? focusedBlock.parent : null, 75 77 ); ··· 104 106 </div> 105 107 </div> 106 108 } 107 - onClick={() => { 109 + onClick={async () => { 108 110 if (!rep || !block) return; 109 - outdent(block, previousBlock, rep); 111 + await outdent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 110 112 }} 111 113 > 112 114 <ListIndentDecreaseSmall /> ··· 124 126 !previousBlock?.listData || 125 127 previousBlock.listData.depth < block?.listData?.depth! 126 128 } 127 - onClick={() => { 129 + onClick={async () => { 128 130 if (!rep || !block || !previousBlock) return; 129 - indent(block, previousBlock, rep); 131 + await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 130 132 }} 131 133 > 132 134 <ListIndentIncreaseSmall /> ··· 134 136 <Separator classname="h-6!" /> 135 137 <ToolbarButton 136 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} 137 160 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 138 161 <div className="text-center">Add a Checkbox</div> 139 162 <div className="flex gap-1 font-normal"> ··· 179 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" 180 203 fill="currentColor" 181 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> 182 257 </svg> 183 258 ); 184 259 };
+2
lexicons/api/index.ts
··· 32 32 import * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 33 33 import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 34 34 import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 35 + import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 35 36 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 36 37 import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 37 38 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' ··· 79 80 export * as PubLeafletBlocksIframe from './types/pub/leaflet/blocks/iframe' 80 81 export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' 81 82 export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' 83 + export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' 82 84 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 83 85 export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 84 86 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text'
+64
lexicons/api/lexicons.ts
··· 1207 1207 }, 1208 1208 }, 1209 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 + }, 1210 1263 PubLeafletBlocksPage: { 1211 1264 lexicon: 1, 1212 1265 id: 'pub.leaflet.blocks.page', ··· 1295 1348 }, 1296 1349 children: { 1297 1350 type: 'array', 1351 + description: 1352 + 'Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.', 1298 1353 items: { 1299 1354 type: 'ref', 1300 1355 ref: 'lex:pub.leaflet.blocks.unorderedList#listItem', 1301 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', 1302 1363 }, 1303 1364 }, 1304 1365 }, ··· 1582 1643 'lex:pub.leaflet.blocks.header', 1583 1644 'lex:pub.leaflet.blocks.image', 1584 1645 'lex:pub.leaflet.blocks.unorderedList', 1646 + 'lex:pub.leaflet.blocks.orderedList', 1585 1647 'lex:pub.leaflet.blocks.website', 1586 1648 'lex:pub.leaflet.blocks.math', 1587 1649 'lex:pub.leaflet.blocks.code', ··· 1683 1745 'lex:pub.leaflet.blocks.header', 1684 1746 'lex:pub.leaflet.blocks.image', 1685 1747 'lex:pub.leaflet.blocks.unorderedList', 1748 + 'lex:pub.leaflet.blocks.orderedList', 1686 1749 'lex:pub.leaflet.blocks.website', 1687 1750 'lex:pub.leaflet.blocks.math', 1688 1751 'lex:pub.leaflet.blocks.code', ··· 2454 2517 PubLeafletBlocksIframe: 'pub.leaflet.blocks.iframe', 2455 2518 PubLeafletBlocksImage: 'pub.leaflet.blocks.image', 2456 2519 PubLeafletBlocksMath: 'pub.leaflet.blocks.math', 2520 + PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', 2457 2521 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2458 2522 PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2459 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 12 import type * as PubLeafletBlocksText from './text' 13 13 import type * as PubLeafletBlocksHeader from './header' 14 14 import type * as PubLeafletBlocksImage from './image' 15 + import type * as PubLeafletBlocksOrderedList from './orderedList' 15 16 16 17 const is$typed = _is$typed, 17 18 validate = _validate ··· 39 40 | $Typed<PubLeafletBlocksHeader.Main> 40 41 | $Typed<PubLeafletBlocksImage.Main> 41 42 | { $type: string } 43 + /** Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. */ 42 44 children?: ListItem[] 45 + orderedListChildren?: PubLeafletBlocksOrderedList.Main 43 46 } 44 47 45 48 const hashListItem = 'listItem'
+2
lexicons/api/types/pub/leaflet/pages/canvas.ts
··· 15 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' 18 19 import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 20 import type * as PubLeafletBlocksMath from '../blocks/math' 20 21 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 53 54 | $Typed<PubLeafletBlocksHeader.Main> 54 55 | $Typed<PubLeafletBlocksImage.Main> 55 56 | $Typed<PubLeafletBlocksUnorderedList.Main> 57 + | $Typed<PubLeafletBlocksOrderedList.Main> 56 58 | $Typed<PubLeafletBlocksWebsite.Main> 57 59 | $Typed<PubLeafletBlocksMath.Main> 58 60 | $Typed<PubLeafletBlocksCode.Main>
+2
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 15 15 import type * as PubLeafletBlocksHeader from '../blocks/header' 16 16 import type * as PubLeafletBlocksImage from '../blocks/image' 17 17 import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' 18 + import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' 18 19 import type * as PubLeafletBlocksWebsite from '../blocks/website' 19 20 import type * as PubLeafletBlocksMath from '../blocks/math' 20 21 import type * as PubLeafletBlocksCode from '../blocks/code' ··· 53 54 | $Typed<PubLeafletBlocksHeader.Main> 54 55 | $Typed<PubLeafletBlocksImage.Main> 55 56 | $Typed<PubLeafletBlocksUnorderedList.Main> 57 + | $Typed<PubLeafletBlocksOrderedList.Main> 56 58 | $Typed<PubLeafletBlocksWebsite.Main> 57 59 | $Typed<PubLeafletBlocksMath.Main> 58 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 33 }, 34 34 "children": { 35 35 "type": "array", 36 + "description": "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", 36 37 "items": { 37 38 "type": "ref", 38 39 "ref": "#listItem" 39 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" 40 46 } 41 47 } 42 48 }
+1
lexicons/pub/leaflet/pages/canvas.json
··· 38 38 "pub.leaflet.blocks.header", 39 39 "pub.leaflet.blocks.image", 40 40 "pub.leaflet.blocks.unorderedList", 41 + "pub.leaflet.blocks.orderedList", 41 42 "pub.leaflet.blocks.website", 42 43 "pub.leaflet.blocks.math", 43 44 "pub.leaflet.blocks.code",
+1
lexicons/pub/leaflet/pages/linearDocument.json
··· 35 35 "pub.leaflet.blocks.header", 36 36 "pub.leaflet.blocks.image", 37 37 "pub.leaflet.blocks.unorderedList", 38 + "pub.leaflet.blocks.orderedList", 38 39 "pub.leaflet.blocks.website", 39 40 "pub.leaflet.blocks.math", 40 41 "pub.leaflet.blocks.code",
+25 -3
lexicons/src/blocks.ts
··· 201 201 type: "object", 202 202 required: ["children"], 203 203 properties: { 204 - startIndex: { type: "integer" }, 204 + startIndex: { 205 + type: "integer", 206 + description: "The starting number for this ordered list. Defaults to 1 if not specified.", 207 + }, 205 208 children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 206 209 }, 207 210 }, ··· 217 220 PubLeafletBlocksImage, 218 221 ].map((l) => l.id), 219 222 }, 220 - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 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 + }, 221 233 }, 222 234 }, 223 235 }, ··· 246 258 PubLeafletBlocksImage, 247 259 ].map((l) => l.id), 248 260 }, 249 - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 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 + }, 250 271 }, 251 272 }, 252 273 }, ··· 303 324 PubLeafletBlocksHeader, 304 325 PubLeafletBlocksImage, 305 326 PubLeafletBlocksUnorderedList, 327 + PubLeafletBlocksOrderedList, 306 328 PubLeafletBlocksWebsite, 307 329 PubLeafletBlocksMath, 308 330 PubLeafletBlocksCode,
+2 -134
src/hooks/queries/useBlocks.ts
··· 1 - import { Block } from "components/Blocks/Block"; 2 1 import { useMemo } from "react"; 3 - import { ReadTransaction } from "replicache"; 4 2 import { useSubscribe } from "src/replicache/useSubscribe"; 5 - import { Fact, useReplicache } from "src/replicache"; 3 + import { useReplicache } from "src/replicache"; 6 4 import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 5 + import { getBlocksWithType, getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 7 6 8 7 export const useBlocks = (entityID: string | null) => { 9 8 let rep = useReplicache(); ··· 69 68 }); 70 69 }; 71 70 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 - };
+12
src/replicache/attributes.ts
··· 99 99 type: "string", 100 100 cardinality: "one", 101 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 + }, 102 110 } as const; 103 111 104 112 const MailboxAttributes = { ··· 358 366 "canvas-pattern-union": { 359 367 type: "canvas-pattern-union"; 360 368 value: "dot" | "grid" | "plain"; 369 + }; 370 + "list-style-union": { 371 + type: "list-style-union"; 372 + value: "ordered" | "unordered"; 361 373 }; 362 374 color: { type: "color"; value: string }; 363 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 212 newParent: string; 213 213 after: string; 214 214 block: string; 215 + excludeFromSiblings?: string[]; 215 216 }> = async (args, ctx) => { 216 217 //we should be able to get normal siblings here as we care only about one level 217 218 let newSiblings = ( ··· 225 226 (f) => f.data.value === args.block, 226 227 ); 227 228 if (currentFactIndex === -1) return; 228 - let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1); 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)); 229 234 let currentChildren = ( 230 235 await ctx.scanIndex.eav(args.block, "card/block") 231 236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
+5 -4
src/utils/deleteBlock.ts
··· 2 2 import { ReplicacheMutators } from "src/replicache"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { scanIndex } from "src/replicache/utils"; 5 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 5 + import { getBlocksWithType } from "src/replicache/getBlocks"; 6 6 import { focusBlock } from "src/utils/focusBlock"; 7 7 import { UndoManager } from "src/undoManager"; 8 8 ··· 13 13 ) { 14 14 // get what pagess we need to close as a result of deleting this block 15 15 let pagesToClose = [] as string[]; 16 + 16 17 for (let entity of entities) { 17 18 let [type] = await rep.query((tx) => 18 19 scanIndex(tx).eav(entity, "block/type"), ··· 34 35 } 35 36 } 36 37 37 - // figure out what to focus 38 + // the next and previous blocks in the block list 39 + // if the focused thing is a page and not a block, return 38 40 let focusedBlock = useUIState.getState().focusedEntity; 39 41 let parent = 40 42 focusedBlock?.entityType === "page" ··· 110 112 111 113 // close the pages 112 114 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); 113 - undoManager && undoManager.startGroup(); 114 115 115 - // delete the blocks 116 116 await Promise.all( 117 117 entities.map((entity) => 118 118 rep?.mutate.removeBlock({ ··· 120 120 }), 121 121 ), 122 122 ); 123 + 123 124 undoManager && undoManager.endGroup(); 124 125 }
+13 -4
src/utils/getBlocksAsHTML.tsx
··· 16 16 let parsed = parseBlocksToList(selectedBlocks); 17 17 for (let pb of parsed) { 18 18 if (pb.type === "block") result.push(await renderBlock(pb.block, tx)); 19 - else 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"; 20 23 result.push( 21 - `<ul>${( 24 + `<${tag}>${( 22 25 await Promise.all( 23 26 pb.children.map(async (c) => await renderList(c, tx)), 24 27 ) 25 28 ).join("\n")} 26 - </ul>`, 29 + </${tag}>`, 27 30 ); 31 + } 28 32 } 29 33 return result; 30 34 }); ··· 36 40 await Promise.all(l.children.map(async (c) => await renderList(c, tx))) 37 41 ).join("\n"); 38 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 + 39 48 return `<li ${checked ? `data-checked=${checked.data.value}` : ""}>${await renderBlock(l.block, tx)} ${ 40 49 l.children.length > 0 41 50 ? ` 42 - <ul>${children}</ul> 51 + <${tag}>${children}</${tag}> 43 52 ` 44 53 : "" 45 54 }</li>`;
+110 -27
src/utils/list-operations.ts
··· 1 1 import { Block } from "components/Blocks/Block"; 2 2 import { Replicache } from "replicache"; 3 3 import type { ReplicacheMutators } from "src/replicache"; 4 - import { useUIState } from "src/useUIState"; 5 4 import { v7 } from "uuid"; 6 5 7 - export function indent( 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( 8 31 block: Block, 9 32 previousBlock?: Block, 10 33 rep?: Replicache<ReplicacheMutators> | null, 11 - ) { 12 - if (!block.listData) return false; 13 - if (!previousBlock?.listData) return false; 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 }; 14 43 let depth = block.listData.depth; 15 44 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); 45 + if (!newParent) return { success: false }; 46 + if (foldState && foldState.foldedBlocks.includes(newParent.entity)) 47 + foldState.toggleFold(newParent.entity); 19 48 rep?.mutate.retractFact({ factID: block.factID }); 20 49 rep?.mutate.addLastBlock({ 21 50 parent: newParent.entity, 22 51 factID: v7(), 23 52 entity: block.value, 24 53 }); 25 - return true; 54 + 55 + return { success: true }; 26 56 } 27 57 28 58 export function outdentFull( ··· 38 68 data: { type: "boolean", value: false }, 39 69 }); 40 70 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 71 let after = block.listData?.path.find((f) => f.depth === 1)?.entity; 45 72 46 - // move this block to be after that block 47 73 after && 48 74 after !== block.value && 49 75 rep?.mutate.moveBlock({ ··· 61 87 }); 62 88 } 63 89 64 - export function outdent( 90 + export async function outdent( 65 91 block: Block, 66 - previousBlock: Block | null, 92 + previousBlock?: Block | null, 67 93 rep?: Replicache<ReplicacheMutators> | null, 68 - ) { 69 - if (!block.listData) return false; 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 }; 70 101 let listData = block.listData; 102 + 103 + // All lists use parent/child structure - move blocks between parents 71 104 if (listData.depth === 1) { 72 - rep?.mutate.assertFact({ 105 + await rep?.mutate.assertFact({ 73 106 entity: block.value, 74 107 attribute: "block/is-list", 75 108 data: { type: "boolean", value: false }, 76 109 }); 77 - rep?.mutate.moveChildren({ 110 + await rep?.mutate.moveChildren({ 78 111 oldParent: block.value, 79 112 newParent: block.parent, 80 113 after: block.value, 81 114 }); 115 + return { success: true }; 82 116 } else { 83 - if (!previousBlock || !previousBlock.listData) return false; 84 - let after = previousBlock.listData.path.find( 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( 85 120 (f) => f.depth === listData.depth - 1, 86 121 )?.entity; 87 - if (!after) return false; 122 + if (!after) return { success: false }; 88 123 let parent: string | undefined = undefined; 89 124 if (listData.depth === 2) { 90 125 parent = block.parent; 91 126 } else { 92 - parent = previousBlock.listData.path.find( 127 + parent = listData.path.find( 93 128 (f) => f.depth === listData.depth - 2, 94 129 )?.entity; 95 130 } 96 - if (!parent) return false; 97 - if (useUIState.getState().foldedBlocks.includes(parent)) 98 - useUIState.getState().toggleFold(parent); 99 - rep?.mutate.outdentBlock({ 131 + if (!parent) return { success: false }; 132 + if (foldState && foldState.foldedBlocks.includes(parent)) 133 + foldState.toggleFold(parent); 134 + await rep?.mutate.outdentBlock({ 100 135 block: block.value, 101 136 newParent: parent, 102 137 oldParent: listData.parent, 103 138 after, 139 + excludeFromSiblings, 104 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 + } 105 188 } 106 189 }