From 289d915bd43c50b92d96790b1495c761ea5d21fa Mon Sep 17 00:00:00 2001 From: Grahame Watt Date: Thu, 26 Feb 2026 10:50:53 -0600 Subject: [PATCH] Feat/Ordered Lists (#257) * implement ordered lists * fix readme command --- README.md | 75 ++++++++ actions/publishToPublication.ts | 2 +- .../subscribeToMailboxWithEmail.ts | 2 +- .../[publication]/[rkey]/PostContent.tsx | 73 ++++++++ components/Blocks/Block.tsx | 98 ++++++++-- components/Blocks/BlockCommands.tsx | 25 ++- components/Blocks/MailboxBlock.tsx | 2 +- components/Blocks/TextBlock/inputRules.ts | 45 ++++- components/Blocks/TextBlock/keymap.ts | 41 +++- components/Blocks/TextBlock/useHandlePaste.ts | 36 +++- components/Blocks/useBlockKeyboardHandlers.ts | 11 +- components/Blocks/useBlockMouseHandlers.ts | 2 +- components/SelectionManager/index.tsx | 31 +--- components/SelectionManager/selectionState.ts | 2 +- components/Toolbar/ListToolbar.tsx | 85 ++++++++- lexicons/api/index.ts | 2 + lexicons/api/lexicons.ts | 64 +++++++ .../types/pub/leaflet/blocks/orderedList.ts | 58 ++++++ .../types/pub/leaflet/blocks/unorderedList.ts | 3 + .../api/types/pub/leaflet/pages/canvas.ts | 2 + .../types/pub/leaflet/pages/linearDocument.ts | 2 + lexicons/pub/leaflet/blocks/orderedList.json | 54 ++++++ .../pub/leaflet/blocks/unorderedList.json | 6 + lexicons/pub/leaflet/pages/canvas.json | 1 + .../pub/leaflet/pages/linearDocument.json | 1 + lexicons/src/blocks.ts | 28 ++- src/hooks/queries/useBlocks.ts | 136 +------------- src/replicache/attributes.ts | 12 ++ src/replicache/getBlocks.ts | 175 ++++++++++++++++++ src/replicache/mutations.ts | 7 +- src/utils/deleteBlock.ts | 9 +- src/utils/getBlocksAsHTML.tsx | 17 +- src/utils/list-operations.ts | 137 +++++++++++--- 33 files changed, 1004 insertions(+), 240 deletions(-) create mode 100644 lexicons/api/types/pub/leaflet/blocks/orderedList.ts create mode 100644 lexicons/pub/leaflet/blocks/orderedList.json create mode 100644 src/replicache/getBlocks.ts diff --git a/README.md b/README.md index 3bf15d16..4977be72 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,81 @@ Use Publications on Leaflet for blogs, newsletters, project logs — anything yo Read ours here: [Leaflet Lab Notes](https://lab.leaflet.pub/). +### Local Development (Linux, WSL) + +#### Prerequisites + +- [NodeJS](https://nodejs.org/en) (version 20 or later) +- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started) +- [Docker](https://docker.com) (required for local Supabase) + +#### Installation + +1. Clone the repository `git clone https://tangled.org/leaflet.pub/leaflet.git` + 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`) +2. Install the dependencies: `npm install` +3. Install the Supabase CLI: + - **macOS:** `brew install supabase/tap/supabase` + - **Windows:** `scoop bucket add supabase https://github.com/supabase/scoop-bucket.git && scoop install supabase` + - **Linux:** Use Homebrew or download packages from [releases page](https://github.com/supabase/cli/releases) + - **Via npm:** The CLI is already included in package.json, use `npx supabase` for commands + +#### Local Supabase Setup + +1. Start the local Supabase stack: `npx supabase start` + - First run takes longer while Docker images download + - Once complete, you'll see connection details in the terminal output + - Keep note of the `API URL`, `anon key`, `service_role key`, and `DB URL` +2. Copy the `.env` file example to `.env.local` and update with your local values from the previous step: + +```env +# Supabase Configuration (from `supabase start` output) +NEXT_PUBLIC_SUPABASE_API_URL=http://localhost:54321 +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key-from-terminal +SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key-from-terminal + +# Database (default local connection) +DB_URL=postgresql://postgres:postgres@localhost:54322/postgres + +# Leaflet specific +LEAFLET_APP_PASSWORD=any-password-you-want + +# Feed Service (for publication features, optional) +FEED_SERVICE_URL=http://localhost:3001 +``` + +#### Database Migrations + +1. Apply migrations to your local database: + - First time setup: `npx supabase db reset` (resets database and applies all migrations) + - Apply new migrations only: `npx supabase migration up` (applies unapplied migrations) + - Note: You don't need to link to a remote project for local development +2. Access Supabase Studio at `http://localhost:54323` to view your local database + +#### Running the App + +1. `npm run dev` to start the development server +2. Visit `http://localhost:3000` in your browser + +#### Stopping Local Supabase + +- Run `npx supabase stop` to stop the local Supabase stack +- Add `--no-backup` flag to reset the database on next start + +#### Feed service setup (optional) + +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. + +1. Clone the repo `git clone https://github.com/hyperlink-academy/leaflet-feeds.git` +2. Update your `.env.local` to include the FEED_SERVICE_URL (if not already set): `FEED_SERVICE_URL=http://localhost:3001` +3. Change to the directory and build the docker container `docker build -t leaflet-feeds .` +4. Run the docker container on port 3001 (to avoid conflicts with the main app): `docker run -p 3001:3000 leaflet-feeds` + +#### Troubleshooting + +- 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) +- 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). + ## Technical details The stack: diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 8f5075c0..8ce771c7 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -41,7 +41,7 @@ import { AtUri } from "@atproto/syntax"; import { Json } from "supabase/database.types"; import { $Typed, UnicodeString } from "@atproto/api"; import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; -import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; +import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; import { Lock } from "src/utils/lock"; import type { PubLeafletPublication } from "lexicons/api"; import { diff --git a/actions/subscriptions/subscribeToMailboxWithEmail.ts b/actions/subscriptions/subscribeToMailboxWithEmail.ts index 7f145132..4117bc56 100644 --- a/actions/subscriptions/subscribeToMailboxWithEmail.ts +++ b/actions/subscriptions/subscribeToMailboxWithEmail.ts @@ -6,7 +6,7 @@ import { and, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { email_subscriptions_to_entity } from "drizzle/schema"; import postgres from "postgres"; -import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; +import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; import type { Fact, PermissionToken } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { Database } from "supabase/database.types"; diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 47c27dc0..a660e1a9 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -6,6 +6,7 @@ import { PubLeafletBlocksImage, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, + PubLeafletBlocksOrderedList, PubLeafletBlocksWebsite, PubLeafletDocument, PubLeafletPagesLinearDocument, @@ -238,6 +239,26 @@ export let Block = ({ ); } + case PubLeafletBlocksOrderedList.isMain(b.block): { + return ( +
    + {b.block.children.map((child, i) => ( + + ))} +
+ ); + } case PubLeafletBlocksMath.isMain(b.block): { return ; } @@ -459,3 +480,55 @@ function ListItem(props: { ); } + +function OrderedListItem(props: { + index: number[]; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; + item: PubLeafletBlocksOrderedList.ListItem; + did: string; + className?: string; + bskyPostData: AppBskyFeedDefs.PostView[]; + pollData: PollData[]; + pageId?: string; + startIndex?: number; +}) { + const calculatedIndex = (props.startIndex || 1) + props.index[props.index.length - 1]; + let children = props.item.children?.length ? ( +
    + {props.item.children.map((child, index) => ( + + ))} +
+ ) : null; + return ( +
  • +
    + {calculatedIndex}. +
    +
    + + {children}{" "} +
    +
  • + ); +} diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 33ff933a..1ba258be 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -26,13 +26,11 @@ import { PollBlock } from "./PollBlock"; import { BlueskyPostBlock } from "./BlueskyPostBlock"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; -import { LockTiny } from "components/Icons/LockTiny"; import { MathBlock } from "./MathBlock"; import { CodeBlock } from "./CodeBlock"; import { HorizontalRule } from "./HorizontalRule"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; -import { focusPage } from "src/utils/focusPage"; import { DeleteTiny } from "components/Icons/DeleteTiny"; import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; import { Separator } from "components/Layout"; @@ -47,6 +45,9 @@ export type Block = { type: Fact<"block/type">["data"]["value"]; listData?: { checklist?: boolean; + listStyle?: "ordered" | "unordered"; + listStart?: number; + displayNumber?: number; path: { depth: number; entity: string }[]; parent: string; depth: number; @@ -192,7 +193,9 @@ function deepEqualsBlockProps( if ( prevProps.listData.checklist !== nextProps.listData.checklist || prevProps.listData.parent !== nextProps.listData.parent || - prevProps.listData.depth !== nextProps.listData.depth + prevProps.listData.depth !== nextProps.listData.depth || + prevProps.listData.displayNumber !== nextProps.listData.displayNumber || + prevProps.listData.listStyle !== nextProps.listData.listStyle ) { return false; } @@ -495,6 +498,7 @@ export const ListMarker = ( ) => { let isMobile = useIsMobile(); let checklist = useEntity(props.value, "block/check-list"); + let listStyle = useEntity(props.value, "block/list-style"); let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; let children = useEntity(props.value, "card/block"); let folded = @@ -504,6 +508,43 @@ export const ListMarker = ( let depth = props.listData?.depth; let { permissions } = useEntitySetContext(); let { rep } = useReplicache(); + + let [editingNumber, setEditingNumber] = useState(false); + let [numberInputValue, setNumberInputValue] = useState(""); + + useEffect(() => { + if (!editingNumber) { + setNumberInputValue(""); + } + }, [editingNumber]); + + const handleNumberSave = async () => { + if (!rep || !props.listData) return; + + const newNumber = parseInt(numberInputValue, 10); + if (isNaN(newNumber) || newNumber < 1) { + setEditingNumber(false); + return; + } + + const currentDisplay = props.listData.displayNumber || 1; + + if (newNumber === currentDisplay) { + // Remove override if it matches the computed number + await rep.mutate.retractAttribute({ + entity: props.value, + attribute: "block/list-number", + }); + } else { + await rep.mutate.assertFact({ + entity: props.value, + attribute: "block/list-number", + data: { type: "number", value: newNumber }, + }); + } + + setEditingNumber(false); + }; return (
    0 ? "cursor-pointer" : "cursor-default"}`} > -
    0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` - }`} - /> + {listStyle?.data.value === "ordered" ? ( + editingNumber ? ( + setNumberInputValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={handleNumberSave} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleNumberSave(); + } else if (e.key === "Escape") { + setEditingNumber(false); + } + }} + autoFocus + 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" + /> + ) : ( +
    { + e.stopPropagation(); + if (permissions.write && listStyle?.data.value === "ordered") { + setNumberInputValue(String(props.listData?.displayNumber || 1)); + setEditingNumber(true); + } + }} + > + {props.listData?.displayNumber || 1}. +
    + ) + ) : ( +
    0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` + }`} + /> + )} {checklist && (
    } - onClick={() => { + onClick={async () => { if (!rep || !block) return; - outdent(block, previousBlock, rep); + await outdent(block, previousBlock, rep, { foldedBlocks, toggleFold }); }} > @@ -124,14 +126,35 @@ export const ListToolbar = (props: { onClose: () => void }) => { !previousBlock?.listData || previousBlock.listData.depth < block?.listData?.depth! } - onClick={() => { + onClick={async () => { if (!rep || !block || !previousBlock) return; - indent(block, previousBlock, rep); + await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); }} > + { + if (!block || !rep) return; + unorderListItems(block, rep); + }} + > + + + { + if (!block || !rep) return; + orderListItems(block, rep); + }} + > + + + @@ -183,6 +206,58 @@ export const ListUnorderedSmall = (props: Props) => { ); }; +export const ListOrderedSmall = (props: Props) => { + return ( + + {/* Horizontal lines */} + + {/* Numbers 1, 2, 3 */} + + 1. + + + 2. + + + 3. + + + ); +}; + const ListIndentIncreaseSmall = (props: Props) => { return ( (v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
    (v, id, hashMain) +} + +export interface ListItem { + $type?: 'pub.leaflet.blocks.orderedList#listItem' + content: + | $Typed + | $Typed + | $Typed + | { $type: string } + /** Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence. */ + children?: ListItem[] + unorderedListChildren?: PubLeafletBlocksUnorderedList.Main +} + +const hashListItem = 'listItem' + +export function isListItem(v: V) { + return is$typed(v, id, hashListItem) +} + +export function validateListItem(v: V) { + return validate(v, id, hashListItem) +} diff --git a/lexicons/api/types/pub/leaflet/blocks/unorderedList.ts b/lexicons/api/types/pub/leaflet/blocks/unorderedList.ts index e759f504..5d01e973 100644 --- a/lexicons/api/types/pub/leaflet/blocks/unorderedList.ts +++ b/lexicons/api/types/pub/leaflet/blocks/unorderedList.ts @@ -12,6 +12,7 @@ import { import type * as PubLeafletBlocksText from './text' import type * as PubLeafletBlocksHeader from './header' import type * as PubLeafletBlocksImage from './image' +import type * as PubLeafletBlocksOrderedList from './orderedList' const is$typed = _is$typed, validate = _validate @@ -39,7 +40,9 @@ export interface ListItem { | $Typed | $Typed | { $type: string } + /** Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. */ children?: ListItem[] + orderedListChildren?: PubLeafletBlocksOrderedList.Main } const hashListItem = 'listItem' diff --git a/lexicons/api/types/pub/leaflet/pages/canvas.ts b/lexicons/api/types/pub/leaflet/pages/canvas.ts index 3dd0b3e1..fc55c202 100644 --- a/lexicons/api/types/pub/leaflet/pages/canvas.ts +++ b/lexicons/api/types/pub/leaflet/pages/canvas.ts @@ -15,6 +15,7 @@ import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' import type * as PubLeafletBlocksHeader from '../blocks/header' import type * as PubLeafletBlocksImage from '../blocks/image' import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' +import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' import type * as PubLeafletBlocksWebsite from '../blocks/website' import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' @@ -53,6 +54,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index da860dad..82dd1bf2 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -15,6 +15,7 @@ import type * as PubLeafletBlocksBlockquote from '../blocks/blockquote' import type * as PubLeafletBlocksHeader from '../blocks/header' import type * as PubLeafletBlocksImage from '../blocks/image' import type * as PubLeafletBlocksUnorderedList from '../blocks/unorderedList' +import type * as PubLeafletBlocksOrderedList from '../blocks/orderedList' import type * as PubLeafletBlocksWebsite from '../blocks/website' import type * as PubLeafletBlocksMath from '../blocks/math' import type * as PubLeafletBlocksCode from '../blocks/code' @@ -53,6 +54,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/lexicons/pub/leaflet/blocks/orderedList.json b/lexicons/pub/leaflet/blocks/orderedList.json new file mode 100644 index 00000000..755fdd05 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/orderedList.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.orderedList", + "defs": { + "main": { + "type": "object", + "required": [ + "children" + ], + "properties": { + "startIndex": { + "type": "integer", + "description": "The starting number for this ordered list. Defaults to 1 if not specified." + }, + "children": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listItem" + } + } + } + }, + "listItem": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "union", + "refs": [ + "pub.leaflet.blocks.text", + "pub.leaflet.blocks.header", + "pub.leaflet.blocks.image" + ] + }, + "children": { + "type": "array", + "description": "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", + "items": { + "type": "ref", + "ref": "#listItem" + } + }, + "unorderedListChildren": { + "type": "ref", + "description": "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", + "ref": "pub.leaflet.blocks.unorderedList" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/blocks/unorderedList.json b/lexicons/pub/leaflet/blocks/unorderedList.json index 7d21edee..3254e0c9 100644 --- a/lexicons/pub/leaflet/blocks/unorderedList.json +++ b/lexicons/pub/leaflet/blocks/unorderedList.json @@ -33,10 +33,16 @@ }, "children": { "type": "array", + "description": "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", "items": { "type": "ref", "ref": "#listItem" } + }, + "orderedListChildren": { + "type": "ref", + "description": "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", + "ref": "pub.leaflet.blocks.orderedList" } } } diff --git a/lexicons/pub/leaflet/pages/canvas.json b/lexicons/pub/leaflet/pages/canvas.json index b0d5eb69..2efc04dd 100644 --- a/lexicons/pub/leaflet/pages/canvas.json +++ b/lexicons/pub/leaflet/pages/canvas.json @@ -38,6 +38,7 @@ "pub.leaflet.blocks.header", "pub.leaflet.blocks.image", "pub.leaflet.blocks.unorderedList", + "pub.leaflet.blocks.orderedList", "pub.leaflet.blocks.website", "pub.leaflet.blocks.math", "pub.leaflet.blocks.code", diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index c36536a9..51007d09 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -35,6 +35,7 @@ "pub.leaflet.blocks.header", "pub.leaflet.blocks.image", "pub.leaflet.blocks.unorderedList", + "pub.leaflet.blocks.orderedList", "pub.leaflet.blocks.website", "pub.leaflet.blocks.math", "pub.leaflet.blocks.code", diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index c7d54ccb..b3139e85 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -201,7 +201,10 @@ export const PubLeafletBlocksOrderedList: LexiconDoc = { type: "object", required: ["children"], properties: { - startIndex: { type: "integer" }, + startIndex: { + type: "integer", + description: "The starting number for this ordered list. Defaults to 1 if not specified.", + }, children: { type: "array", items: { type: "ref", ref: "#listItem" } }, }, }, @@ -217,7 +220,16 @@ export const PubLeafletBlocksOrderedList: LexiconDoc = { PubLeafletBlocksImage, ].map((l) => l.id), }, - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + children: { + type: "array", + description: "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", + items: { type: "ref", ref: "#listItem" }, + }, + unorderedListChildren: { + type: "ref", + description: "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", + ref: "pub.leaflet.blocks.unorderedList", + }, }, }, }, @@ -246,7 +258,16 @@ export const PubLeafletBlocksUnorderedList: LexiconDoc = { PubLeafletBlocksImage, ].map((l) => l.id), }, - children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + children: { + type: "array", + description: "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", + items: { type: "ref", ref: "#listItem" }, + }, + orderedListChildren: { + type: "ref", + description: "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", + ref: "pub.leaflet.blocks.orderedList", + }, }, }, }, @@ -303,6 +324,7 @@ export const BlockLexicons = [ PubLeafletBlocksHeader, PubLeafletBlocksImage, PubLeafletBlocksUnorderedList, + PubLeafletBlocksOrderedList, PubLeafletBlocksWebsite, PubLeafletBlocksMath, PubLeafletBlocksCode, diff --git a/src/hooks/queries/useBlocks.ts b/src/hooks/queries/useBlocks.ts index 78354507..3bd4950f 100644 --- a/src/hooks/queries/useBlocks.ts +++ b/src/hooks/queries/useBlocks.ts @@ -1,9 +1,8 @@ -import { Block } from "components/Blocks/Block"; import { useMemo } from "react"; -import { ReadTransaction } from "replicache"; import { useSubscribe } from "src/replicache/useSubscribe"; -import { Fact, useReplicache } from "src/replicache"; +import { useReplicache } from "src/replicache"; import { scanIndex, scanIndexLocal } from "src/replicache/utils"; +import { getBlocksWithType, getBlocksWithTypeLocal } from "src/replicache/getBlocks"; export const useBlocks = (entityID: string | null) => { let rep = useReplicache(); @@ -69,134 +68,3 @@ export const useCanvasBlocksWithType = (entityID: string | null) => { }); }; -export const getBlocksWithType = async ( - tx: ReadTransaction, - entityID: string, -) => { - let initialized = await tx.get("initialized"); - if (!initialized) return null; - let scan = scanIndex(tx); - let blocks = await scan.eav(entityID, "card/block"); - - return ( - await Promise.all( - blocks - .sort((a, b) => { - if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; - return a.data.position > b.data.position ? 1 : -1; - }) - .map(async (b) => { - let type = (await scan.eav(b.data.value, "block/type"))[0]; - let isList = await scan.eav(b.data.value, "block/is-list"); - if (!type) return null; - if (isList[0]?.data.value) { - const getChildren = async ( - root: Fact<"card/block">, - parent: string, - depth: number, - path: { depth: number; entity: string }[], - ): Promise => { - let children = ( - await scan.eav(root.data.value, "card/block") - ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); - let type = (await scan.eav(root.data.value, "block/type"))[0]; - let checklist = await scan.eav( - root.data.value, - "block/check-list", - ); - if (!type) return []; - let newPath = [...path, { entity: root.data.value, depth }]; - let childBlocks = await Promise.all( - children.map((c) => - getChildren(c, root.data.value, depth + 1, newPath), - ), - ); - return [ - { - ...root.data, - factID: root.id, - type: type.data.value, - parent: b.entity, - listData: { - depth: depth, - parent, - path: newPath, - checklist: !!checklist[0], - }, - }, - ...childBlocks.flat(), - ]; - }; - return getChildren(b, b.entity, 1, []); - } - return [ - { - ...b.data, - factID: b.id, - type: type.data.value, - parent: b.entity, - }, - ] as Block[]; - }), - ) - ) - .flat() - .filter((f) => f !== null); -}; - -export const getBlocksWithTypeLocal = ( - initialFacts: Fact[], - entityID: string, -) => { - let scan = scanIndexLocal(initialFacts); - let blocks = scan.eav(entityID, "card/block"); - return blocks - .sort((a, b) => { - if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; - return a.data.position > b.data.position ? 1 : -1; - }) - .map((b) => { - let type = scan.eav(b.data.value, "block/type")[0]; - let isList = scan.eav(b.data.value, "block/is-list"); - if (!type) return null; - if (isList[0]?.data.value) { - const getChildren = ( - root: Fact<"card/block">, - parent: string, - depth: number, - path: { depth: number; entity: string }[], - ): Block[] => { - let children = scan - .eav(root.data.value, "card/block") - .sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); - let type = scan.eav(root.data.value, "block/type")[0]; - if (!type) return []; - let newPath = [...path, { entity: root.data.value, depth }]; - let childBlocks = children.map((c) => - getChildren(c, root.data.value, depth + 1, newPath), - ); - return [ - { - ...root.data, - factID: root.id, - type: type.data.value, - parent: b.entity, - listData: { depth: depth, parent, path: newPath }, - }, - ...childBlocks.flat(), - ]; - }; - return getChildren(b, b.entity, 1, []); - } - return [ - { - ...b.data, - factID: b.id, - type: type.data.value, - parent: b.entity, - }, - ] as Block[]; - }) - .flat() - .filter((f) => f !== null); -}; diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index 5ab061b3..39b9012c 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -99,6 +99,14 @@ const BlockAttributes = { type: "string", cardinality: "one", }, + "block/list-style": { + type: "list-style-union", + cardinality: "one", + }, + "block/list-number": { + type: "number", + cardinality: "one", + }, } as const; const MailboxAttributes = { @@ -359,6 +367,10 @@ export type Data = { type: "canvas-pattern-union"; value: "dot" | "grid" | "plain"; }; + "list-style-union": { + type: "list-style-union"; + value: "ordered" | "unordered"; + }; color: { type: "color"; value: string }; }[(typeof Attributes)[A]["type"]]; export type FilterAttributes> = diff --git a/src/replicache/getBlocks.ts b/src/replicache/getBlocks.ts new file mode 100644 index 00000000..51367288 --- /dev/null +++ b/src/replicache/getBlocks.ts @@ -0,0 +1,175 @@ +import { Block } from "components/Blocks/Block"; +import { ReadTransaction } from "replicache"; +import { Fact } from "src/replicache"; +import { scanIndex, scanIndexLocal } from "src/replicache/utils"; + +function computeDisplayNumbers(blocks: Block[]): void { + let counters = new Map(); + for (let block of blocks) { + if (!block.listData) { + counters.clear(); + continue; + } + if (block.listData.listStyle !== "ordered") continue; + let parent = block.listData.parent; + if (block.listData.listStart !== undefined) { + counters.set(parent, block.listData.listStart); + } else if (!counters.has(parent)) { + counters.set(parent, 1); + } + block.listData.displayNumber = counters.get(parent)!; + counters.set(parent, counters.get(parent)! + 1); + } +} + +export const getBlocksWithType = async ( + tx: ReadTransaction, + entityID: string, +) => { + let initialized = await tx.get("initialized"); + if (!initialized) return null; + let scan = scanIndex(tx); + let blocks = await scan.eav(entityID, "card/block"); + + let result = ( + await Promise.all( + blocks + .sort((a, b) => { + if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; + return a.data.position > b.data.position ? 1 : -1; + }) + .map(async (b) => { + let type = (await scan.eav(b.data.value, "block/type"))[0]; + let isList = await scan.eav(b.data.value, "block/is-list"); + if (!type) return null; + // All lists use recursive structure + if (isList[0]?.data.value) { + const getChildren = async ( + root: Fact<"card/block">, + parent: string, + depth: number, + path: { depth: number; entity: string }[], + ): Promise => { + let children = ( + await scan.eav(root.data.value, "card/block") + ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); + let type = (await scan.eav(root.data.value, "block/type"))[0]; + let checklist = await scan.eav( + root.data.value, + "block/check-list", + ); + let listStyle = (await scan.eav(root.data.value, "block/list-style"))[0]; + let listNumber = (await scan.eav(root.data.value, "block/list-number"))[0]; + if (!type) return []; + let newPath = [...path, { entity: root.data.value, depth }]; + let childBlocks = await Promise.all( + children.map((c) => + getChildren(c, root.data.value, depth + 1, newPath), + ), + ); + return [ + { + ...root.data, + factID: root.id, + type: type.data.value, + parent: b.entity, + listData: { + depth: depth, + parent, + path: newPath, + checklist: !!checklist[0], + listStyle: listStyle?.data.value, + listStart: listNumber?.data.value, + }, + }, + ...childBlocks.flat(), + ]; + }; + return getChildren(b, b.entity, 1, []); + } + return [ + { + ...b.data, + factID: b.id, + type: type.data.value, + parent: b.entity, + }, + ] as Block[]; + }), + ) + ) + .flat() + .filter((f) => f !== null); + + computeDisplayNumbers(result); + return result; +}; + +export const getBlocksWithTypeLocal = ( + initialFacts: Fact[], + entityID: string, +) => { + let scan = scanIndexLocal(initialFacts); + let blocks = scan.eav(entityID, "card/block"); + let result = blocks + .sort((a, b) => { + if (a.data.position === b.data.position) return a.id > b.id ? 1 : -1; + return a.data.position > b.data.position ? 1 : -1; + }) + .map((b) => { + let type = scan.eav(b.data.value, "block/type")[0]; + let isList = scan.eav(b.data.value, "block/is-list"); + if (!type) return null; + // All lists use recursive structure + if (isList[0]?.data.value) { + const getChildren = ( + root: Fact<"card/block">, + parent: string, + depth: number, + path: { depth: number; entity: string }[], + ): Block[] => { + let children = scan + .eav(root.data.value, "card/block") + .sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); + let type = scan.eav(root.data.value, "block/type")[0]; + let listStyle = scan.eav(root.data.value, "block/list-style")[0]; + let listNumber = scan.eav(root.data.value, "block/list-number")[0]; + if (!type) return []; + let newPath = [...path, { entity: root.data.value, depth }]; + let childBlocks = children.map((c) => + getChildren(c, root.data.value, depth + 1, newPath), + ); + return [ + { + ...root.data, + factID: root.id, + type: type.data.value, + parent: b.entity, + listData: { + depth: depth, + parent, + path: newPath, + listStyle: listStyle?.data.value, + listStart: listNumber?.data.value, + }, + }, + ...childBlocks.flat(), + ]; + }; + return getChildren(b, b.entity, 1, []); + } + return [ + { + ...b.data, + factID: b.id, + type: type.data.value, + parent: b.entity, + }, + ] as Block[]; + }) + .flat() + .filter((f) => f !== null); + + computeDisplayNumbers(result); + return result; +}; diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 232d4249..1bf779be 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -212,6 +212,7 @@ const outdentBlock: Mutation<{ newParent: string; after: string; block: string; + excludeFromSiblings?: string[]; }> = async (args, ctx) => { //we should be able to get normal siblings here as we care only about one level let newSiblings = ( @@ -225,7 +226,11 @@ const outdentBlock: Mutation<{ (f) => f.data.value === args.block, ); if (currentFactIndex === -1) return; - let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1); + // Filter out blocks that are being processed separately (e.g., in multi-select outdent) + let excludeSet = new Set(args.excludeFromSiblings || []); + let currentSiblingsAfter = currentSiblings + .slice(currentFactIndex + 1) + .filter((sib) => !excludeSet.has(sib.data.value)); let currentChildren = ( await ctx.scanIndex.eav(args.block, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); diff --git a/src/utils/deleteBlock.ts b/src/utils/deleteBlock.ts index bc35e9f6..84f3e9e3 100644 --- a/src/utils/deleteBlock.ts +++ b/src/utils/deleteBlock.ts @@ -2,7 +2,7 @@ import { Replicache } from "replicache"; import { ReplicacheMutators } from "src/replicache"; import { useUIState } from "src/useUIState"; import { scanIndex } from "src/replicache/utils"; -import { getBlocksWithType } from "src/hooks/queries/useBlocks"; +import { getBlocksWithType } from "src/replicache/getBlocks"; import { focusBlock } from "src/utils/focusBlock"; import { UndoManager } from "src/undoManager"; @@ -13,6 +13,7 @@ export async function deleteBlock( ) { // get what pagess we need to close as a result of deleting this block let pagesToClose = [] as string[]; + for (let entity of entities) { let [type] = await rep.query((tx) => scanIndex(tx).eav(entity, "block/type"), @@ -34,7 +35,8 @@ export async function deleteBlock( } } - // figure out what to focus + // the next and previous blocks in the block list + // if the focused thing is a page and not a block, return let focusedBlock = useUIState.getState().focusedEntity; let parent = focusedBlock?.entityType === "page" @@ -110,9 +112,7 @@ export async function deleteBlock( // close the pages pagesToClose.forEach((page) => page && useUIState.getState().closePage(page)); - undoManager && undoManager.startGroup(); - // delete the blocks await Promise.all( entities.map((entity) => rep?.mutate.removeBlock({ @@ -120,5 +120,6 @@ export async function deleteBlock( }), ), ); + undoManager && undoManager.endGroup(); } diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx index d9ac5b73..182e7ac6 100644 --- a/src/utils/getBlocksAsHTML.tsx +++ b/src/utils/getBlocksAsHTML.tsx @@ -16,15 +16,19 @@ export async function getBlocksAsHTML( let parsed = parseBlocksToList(selectedBlocks); for (let pb of parsed) { if (pb.type === "block") result.push(await renderBlock(pb.block, tx)); - else + else { + // Check if the first child is an ordered list + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; result.push( - `
      ${( + `<${tag}>${( await Promise.all( pb.children.map(async (c) => await renderList(c, tx)), ) ).join("\n")} -
    `, + `, ); + } } return result; }); @@ -36,10 +40,15 @@ async function renderList(l: List, tx: ReadTransaction): Promise { await Promise.all(l.children.map(async (c) => await renderList(c, tx))) ).join("\n"); let [checked] = await scanIndex(tx).eav(l.block.value, "block/check-list"); + + // Check if nested children are ordered or unordered + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; + return `
  • ${await renderBlock(l.block, tx)} ${ l.children.length > 0 ? ` -
      ${children}
    + <${tag}>${children} ` : "" }
  • `; diff --git a/src/utils/list-operations.ts b/src/utils/list-operations.ts index 7ceff234..c448a014 100644 --- a/src/utils/list-operations.ts +++ b/src/utils/list-operations.ts @@ -1,28 +1,58 @@ import { Block } from "components/Blocks/Block"; import { Replicache } from "replicache"; import type { ReplicacheMutators } from "src/replicache"; -import { useUIState } from "src/useUIState"; import { v7 } from "uuid"; -export function indent( +export function orderListItems( + block: Block, + rep?: Replicache | null, +) { + if (!block.listData) return; + rep?.mutate.assertFact({ + entity: block.value, + attribute: "block/list-style", + data: { type: "list-style-union", value: "ordered" }, + }); +} + +export function unorderListItems( block: Block, - previousBlock?: Block, rep?: Replicache | null, ) { - if (!block.listData) return false; - if (!previousBlock?.listData) return false; + if (!block.listData) return; + // Remove list-style attribute to convert back to unordered + rep?.mutate.retractAttribute({ + entity: block.value, + attribute: "block/list-style", + }); +} + +export async function indent( + block: Block, + previousBlock?: Block, + rep?: Replicache | null, + foldState?: { + foldedBlocks: string[]; + toggleFold: (entityID: string) => void; + }, +): Promise<{ success: boolean }> { + if (!block.listData) return { success: false }; + + // All lists use parent/child structure - move to new parent + if (!previousBlock?.listData) return { success: false }; let depth = block.listData.depth; let newParent = previousBlock.listData.path.find((f) => f.depth === depth); - if (!newParent) return false; - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) - useUIState.getState().toggleFold(newParent.entity); + if (!newParent) return { success: false }; + if (foldState && foldState.foldedBlocks.includes(newParent.entity)) + foldState.toggleFold(newParent.entity); rep?.mutate.retractFact({ factID: block.factID }); rep?.mutate.addLastBlock({ parent: newParent.entity, factID: v7(), entity: block.value, }); - return true; + + return { success: true }; } export function outdentFull( @@ -38,12 +68,8 @@ export function outdentFull( data: { type: "boolean", value: false }, }); - // find the next block that is a level 1 list item or not a list item. - // If there are none or this block is a level 1 list item, we don't need to move anything - let after = block.listData?.path.find((f) => f.depth === 1)?.entity; - // move this block to be after that block after && after !== block.value && rep?.mutate.moveBlock({ @@ -61,46 +87,103 @@ export function outdentFull( }); } -export function outdent( +export async function outdent( block: Block, - previousBlock: Block | null, + previousBlock?: Block | null, rep?: Replicache | null, -) { - if (!block.listData) return false; + foldState?: { + foldedBlocks: string[]; + toggleFold: (entityID: string) => void; + }, + excludeFromSiblings?: string[], +): Promise<{ success: boolean }> { + if (!block.listData) return { success: false }; let listData = block.listData; + + // All lists use parent/child structure - move blocks between parents if (listData.depth === 1) { - rep?.mutate.assertFact({ + await rep?.mutate.assertFact({ entity: block.value, attribute: "block/is-list", data: { type: "boolean", value: false }, }); - rep?.mutate.moveChildren({ + await rep?.mutate.moveChildren({ oldParent: block.value, newParent: block.parent, after: block.value, }); + return { success: true }; } else { - if (!previousBlock || !previousBlock.listData) return false; - let after = previousBlock.listData.path.find( + // Use block's own path for ancestry lookups - it always has correct info + // even in multiselect scenarios where previousBlock may be stale + let after = listData.path.find( (f) => f.depth === listData.depth - 1, )?.entity; - if (!after) return false; + if (!after) return { success: false }; let parent: string | undefined = undefined; if (listData.depth === 2) { parent = block.parent; } else { - parent = previousBlock.listData.path.find( + parent = listData.path.find( (f) => f.depth === listData.depth - 2, )?.entity; } - if (!parent) return false; - if (useUIState.getState().foldedBlocks.includes(parent)) - useUIState.getState().toggleFold(parent); - rep?.mutate.outdentBlock({ + if (!parent) return { success: false }; + if (foldState && foldState.foldedBlocks.includes(parent)) + foldState.toggleFold(parent); + await rep?.mutate.outdentBlock({ block: block.value, newParent: parent, oldParent: listData.parent, after, + excludeFromSiblings, }); + + return { success: true }; + } +} + +export async function multiSelectOutdent( + sortedSelection: Block[], + siblings: Block[], + rep: Replicache, + foldState: { foldedBlocks: string[]; toggleFold: (entityID: string) => void }, +): Promise { + let pageParent = siblings[0]?.parent; + if (!pageParent) return; + + let selectedSet = new Set(sortedSelection.map((b) => b.value)); + let selectedEntities = sortedSelection.map((b) => b.value); + + // Check if all selected list items are at depth 1 → convert to text + let allAtDepth1 = sortedSelection.every( + (b) => !b.listData || b.listData.depth === 1, + ); + + if (allAtDepth1) { + // Convert depth-1 items to plain text (outdent handles this) + for (let i = siblings.length - 1; i >= 0; i--) { + let block = siblings[i]; + if (!selectedSet.has(block.value)) continue; + if (!block.listData) continue; + await outdent(block, null, rep, foldState, selectedEntities); + } + } else { + // Normal outdent: iterate backward through siblings + for (let i = siblings.length - 1; i >= 0; i--) { + let block = siblings[i]; + if (!selectedSet.has(block.value)) continue; + if (!block.listData) continue; + if (block.listData.depth === 1) continue; + + // Skip if parent is selected AND parent's depth > 1 + let parentEntity = block.listData.parent; + if (selectedSet.has(parentEntity)) { + let parentBlock = siblings.find((s) => s.value === parentEntity); + if (parentBlock?.listData && parentBlock.listData.depth > 1) continue; + } + + await outdent(block, null, rep, foldState, selectedEntities); + } } } -- 2.51.2