From 6e068403c77915e8953c676a886379a906cc9bea Mon Sep 17 00:00:00 2001 From: Grahame Watt Date: Sat, 17 Jan 2026 10:41:40 -0600 Subject: [PATCH 1/2] ordered lists --- actions/publishToPublication.ts | 2 +- .../subscribeToMailboxWithEmail.ts | 2 +- components/Blocks/Block.tsx | 115 ++++++++++++-- components/Blocks/BlockCommands.tsx | 30 +++- components/Blocks/MailboxBlock.tsx | 2 +- components/Blocks/TextBlock/inputRules.ts | 42 ++++- components/Blocks/TextBlock/keymap.ts | 67 +++++++- components/Blocks/useBlockKeyboardHandlers.ts | 7 +- components/Blocks/useBlockMouseHandlers.ts | 2 +- components/SelectionManager/index.tsx | 8 +- components/SelectionManager/selectionState.ts | 2 +- components/Toolbar/ListToolbar.tsx | 83 +++++++++- components/Toolbar/MultiSelectToolbar.tsx | 2 +- src/hooks/queries/useBlocks.ts | 136 +--------------- src/replicache/attributes.ts | 12 ++ src/replicache/getBlocks.ts | 150 ++++++++++++++++++ src/utils/deleteBlock.ts | 2 +- src/utils/getBlocksAsHTML.tsx | 17 +- src/utils/list-operations.ts | 91 ++++++++++- 19 files changed, 591 insertions(+), 181 deletions(-) create mode 100644 src/replicache/getBlocks.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 4f7e8b38..4b94bec4 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -39,7 +39,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/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 64efceec..099163de 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -33,6 +33,7 @@ import { HorizontalRule } from "./HorizontalRule"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; import { focusPage } from "src/utils/focusPage"; +import { getBlocksWithType } from "src/replicache/getBlocks"; export type Block = { factID: string; @@ -42,6 +43,8 @@ export type Block = { type: Fact<"block/type">["data"]["value"]; listData?: { checklist?: boolean; + listStyle?: "ordered" | "unordered"; + listNumber?: number; path: { depth: number; entity: string }[]; parent: string; depth: number; @@ -172,7 +175,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.listNumber !== nextProps.listData.listNumber || + prevProps.listData.listStyle !== nextProps.listData.listStyle ) { return false; } @@ -420,6 +425,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 = @@ -429,6 +435,62 @@ 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 oldNumber = props.listData.listNumber || 1; + const difference = newNumber - oldNumber; + + if (difference === 0) { + setEditingNumber(false); + return; + } + + // Update this block's number + await rep.mutate.assertFact({ + entity: props.value, + attribute: "block/list-number", + data: { type: "number", value: newNumber }, + }); + + // Cascade to following blocks at the same depth + const allBlocks = await rep.query((tx) => getBlocksWithType(tx, props.parent)); + if (allBlocks) { + const currentIndex = allBlocks.findIndex((b) => b.value === props.value); + for (let i = currentIndex + 1; i < allBlocks.length; i++) { + const block = allBlocks[i]; + if ( + block.listData?.listStyle === "ordered" && + block.listData?.depth === props.listData.depth + ) { + const currentNumber = block.listData.listNumber || 1; + await rep.mutate.assertFact({ + entity: block.value, + attribute: "block/list-number", + data: { type: "number", value: currentNumber + difference }, + }); + } + } + } + + 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-[2.5rem] bg-transparent border border-accent focus:outline-none px-1" + /> + ) : ( +
{ + e.stopPropagation(); + if (permissions.write && listStyle?.data.value === "ordered") { + setNumberInputValue(String(props.listData?.listNumber || 1)); + setEditingNumber(true); + } + }} + > + {props.listData?.listNumber || 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 }); }} > @@ -126,12 +128,33 @@ export const ListToolbar = (props: { onClose: () => void }) => { } onClick={() => { if (!rep || !block || !previousBlock) return; - indent(block, previousBlock, rep); + 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 ( { 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 4f00e8e9..3d69681c 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 = { @@ -355,6 +363,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..baf92fa1 --- /dev/null +++ b/src/replicache/getBlocks.ts @@ -0,0 +1,150 @@ +import { Block } from "components/Blocks/Block"; +import { ReadTransaction } from "replicache"; +import { Fact } from "src/replicache"; +import { scanIndex, scanIndexLocal } from "src/replicache/utils"; + +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; + // 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, + listNumber: 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); +}; + +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; + // 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, + listNumber: 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); +}; diff --git a/src/utils/deleteBlock.ts b/src/utils/deleteBlock.ts index 50eb3b32..35e8056e 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"; export async function deleteBlock( diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx index 98bf380a..86b68ba5 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..e0a01ad3 100644 --- a/src/utils/list-operations.ts +++ b/src/utils/list-operations.ts @@ -1,27 +1,67 @@ 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"; +import { getBlocksWithType } from "src/replicache/getBlocks"; + +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, + rep?: Replicache | null, +) { + if (!block.listData) return; + // Remove list-style attribute to convert back to unordered + rep?.mutate.retractAttribute({ + entity: block.value, + attribute: "block/list-style", + }); +} export function indent( block: Block, previousBlock?: Block, rep?: Replicache | null, + foldState?: { + foldedBlocks: string[]; + toggleFold: (entityID: string) => void; + }, ) { if (!block.listData) return false; + + // All lists use parent/child structure - move to new parent if (!previousBlock?.listData) return 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 (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, }); + + // Reset number to 1 for ordered lists when indenting + if (block.listData.listStyle === "ordered") { + rep?.mutate.assertFact({ + entity: block.value, + attribute: "block/list-number", + data: { type: "number", value: 1 }, + }); + } + return true; } @@ -38,6 +78,7 @@ export function outdentFull( data: { type: "boolean", value: false }, }); + // All lists use nested structure - need to handle parent/child structure // 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 @@ -61,13 +102,19 @@ export function outdentFull( }); } -export function outdent( +export async function outdent( block: Block, previousBlock: Block | null, rep?: Replicache | null, + foldState?: { + foldedBlocks: string[]; + toggleFold: (entityID: string) => void; + }, ) { if (!block.listData) return false; let listData = block.listData; + + // All lists use parent/child structure - move blocks between parents if (listData.depth === 1) { rep?.mutate.assertFact({ entity: block.value, @@ -94,13 +141,45 @@ export function outdent( )?.entity; } if (!parent) return false; - if (useUIState.getState().foldedBlocks.includes(parent)) - useUIState.getState().toggleFold(parent); + if (foldState && foldState.foldedBlocks.includes(parent)) + foldState.toggleFold(parent); rep?.mutate.outdentBlock({ block: block.value, newParent: parent, oldParent: listData.parent, after, }); + + // For ordered lists, calculate the next number at the new depth + if (listData.listStyle === "ordered") { + let targetDepth = listData.depth - 1; + let allBlocks = await rep?.query((tx) => + getBlocksWithType(tx, block.parent), + ); + let previousAtDepth: Block | null = null; + if (allBlocks) { + let currentIndex = allBlocks.findIndex((b) => b.value === block.value); + for (let i = currentIndex - 1; i >= 0; i--) { + let b = allBlocks[i]; + if ( + b.listData?.listStyle === "ordered" && + b.listData?.depth === targetDepth + ) { + previousAtDepth = b; + break; + } + } + } + + let nextNumber = previousAtDepth?.listData?.listNumber + ? previousAtDepth.listData.listNumber + 1 + : 1; + + rep?.mutate.assertFact({ + entity: block.value, + attribute: "block/list-number", + data: { type: "number", value: nextNumber }, + }); + } } } From 7eaf3b8dfa2a34a304b2dc65c194891f2e663c44 Mon Sep 17 00:00:00 2001 From: Grahame Watt Date: Sun, 18 Jan 2026 10:14:39 -0600 Subject: [PATCH 2/2] tweak list number edit box, start list at typed number --- components/Blocks/Block.tsx | 2 +- components/Blocks/TextBlock/inputRules.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 099163de..91a7ec75 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -535,7 +535,7 @@ export const ListMarker = ( } }} autoFocus - className="text-secondary font-normal text-right min-w-[2rem] w-[2.5rem] bg-transparent border border-accent focus:outline-none px-1" + className="text-secondary font-normal text-right min-w-[2rem] w-[2.2rem] bg-transparent border border-accent focus:outline-none px-1" /> ) : (
    { if (propsRef.current.listData) return null; let tr = state.tr; tr.delete(0, match[0].length); + const startNumber = parseInt(match[1], 10); repRef.current?.mutate.assertFact([ { entity: propsRef.current.entityID, @@ -188,7 +189,7 @@ export const inputrules = ( { entity: propsRef.current.entityID, attribute: "block/list-number", - data: { type: "number", value: 1 }, + data: { type: "number", value: startNumber }, }, ]); return tr;