a tool for shared writing and social publishing
at feature/footnotes 189 lines 5.7 kB view raw
1import { Block } from "components/Blocks/Block"; 2import { Replicache } from "replicache"; 3import type { ReplicacheMutators } from "src/replicache"; 4import { v7 } from "uuid"; 5 6export 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 18export 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 30export async function indent( 31 block: Block, 32 previousBlock?: Block, 33 rep?: Replicache<ReplicacheMutators> | null, 34 foldState?: { 35 foldedBlocks: string[]; 36 toggleFold: (entityID: string) => void; 37 }, 38): Promise<{ success: boolean }> { 39 if (!block.listData) return { success: false }; 40 41 // All lists use parent/child structure - move to new parent 42 if (!previousBlock?.listData) return { success: false }; 43 let depth = block.listData.depth; 44 let newParent = previousBlock.listData.path.find((f) => f.depth === depth); 45 if (!newParent) return { success: false }; 46 if (foldState && foldState.foldedBlocks.includes(newParent.entity)) 47 foldState.toggleFold(newParent.entity); 48 rep?.mutate.retractFact({ factID: block.factID }); 49 rep?.mutate.addLastBlock({ 50 parent: newParent.entity, 51 factID: v7(), 52 entity: block.value, 53 }); 54 55 return { success: true }; 56} 57 58export function outdentFull( 59 block: Block, 60 rep?: Replicache<ReplicacheMutators> | null, 61) { 62 if (!block.listData) return; 63 64 // make this block not a list 65 rep?.mutate.assertFact({ 66 entity: block.value, 67 attribute: "block/is-list", 68 data: { type: "boolean", value: false }, 69 }); 70 71 let after = block.listData?.path.find((f) => f.depth === 1)?.entity; 72 73 after && 74 after !== block.value && 75 rep?.mutate.moveBlock({ 76 block: block.value, 77 oldParent: block.listData.parent, 78 newParent: block.parent, 79 position: { type: "after", entity: after }, 80 }); 81 82 // move all the childen to the be under it as a level 1 list item 83 rep?.mutate.moveChildren({ 84 oldParent: block.value, 85 newParent: block.parent, 86 after: block.value, 87 }); 88} 89 90export async function outdent( 91 block: Block, 92 previousBlock?: Block | null, 93 rep?: Replicache<ReplicacheMutators> | null, 94 foldState?: { 95 foldedBlocks: string[]; 96 toggleFold: (entityID: string) => void; 97 }, 98 excludeFromSiblings?: string[], 99): Promise<{ success: boolean }> { 100 if (!block.listData) return { success: false }; 101 let listData = block.listData; 102 103 // All lists use parent/child structure - move blocks between parents 104 if (listData.depth === 1) { 105 await rep?.mutate.assertFact({ 106 entity: block.value, 107 attribute: "block/is-list", 108 data: { type: "boolean", value: false }, 109 }); 110 await rep?.mutate.moveChildren({ 111 oldParent: block.value, 112 newParent: block.parent, 113 after: block.value, 114 }); 115 return { success: true }; 116 } else { 117 // Use block's own path for ancestry lookups - it always has correct info 118 // even in multiselect scenarios where previousBlock may be stale 119 let after = listData.path.find( 120 (f) => f.depth === listData.depth - 1, 121 )?.entity; 122 if (!after) return { success: false }; 123 let parent: string | undefined = undefined; 124 if (listData.depth === 2) { 125 parent = block.parent; 126 } else { 127 parent = listData.path.find( 128 (f) => f.depth === listData.depth - 2, 129 )?.entity; 130 } 131 if (!parent) return { success: false }; 132 if (foldState && foldState.foldedBlocks.includes(parent)) 133 foldState.toggleFold(parent); 134 await rep?.mutate.outdentBlock({ 135 block: block.value, 136 newParent: parent, 137 oldParent: listData.parent, 138 after, 139 excludeFromSiblings, 140 }); 141 142 return { success: true }; 143 } 144} 145 146export async function multiSelectOutdent( 147 sortedSelection: Block[], 148 siblings: Block[], 149 rep: Replicache<ReplicacheMutators>, 150 foldState: { foldedBlocks: string[]; toggleFold: (entityID: string) => void }, 151): Promise<void> { 152 let pageParent = siblings[0]?.parent; 153 if (!pageParent) return; 154 155 let selectedSet = new Set(sortedSelection.map((b) => b.value)); 156 let selectedEntities = sortedSelection.map((b) => b.value); 157 158 // Check if all selected list items are at depth 1 → convert to text 159 let allAtDepth1 = sortedSelection.every( 160 (b) => !b.listData || b.listData.depth === 1, 161 ); 162 163 if (allAtDepth1) { 164 // Convert depth-1 items to plain text (outdent handles this) 165 for (let i = siblings.length - 1; i >= 0; i--) { 166 let block = siblings[i]; 167 if (!selectedSet.has(block.value)) continue; 168 if (!block.listData) continue; 169 await outdent(block, null, rep, foldState, selectedEntities); 170 } 171 } else { 172 // Normal outdent: iterate backward through siblings 173 for (let i = siblings.length - 1; i >= 0; i--) { 174 let block = siblings[i]; 175 if (!selectedSet.has(block.value)) continue; 176 if (!block.listData) continue; 177 if (block.listData.depth === 1) continue; 178 179 // Skip if parent is selected AND parent's depth > 1 180 let parentEntity = block.listData.parent; 181 if (selectedSet.has(parentEntity)) { 182 let parentBlock = siblings.find((s) => s.value === parentEntity); 183 if (parentBlock?.listData && parentBlock.listData.depth > 1) continue; 184 } 185 186 await outdent(block, null, rep, foldState, selectedEntities); 187 } 188 } 189}