a tool for shared writing and social publishing
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}