implement outliner functionality
[apps/outliner/.git] / src / outline.ts
1 import * as _ from 'lodash';
2 import { v4 as uuid } from 'uuid';
3 import { marked } from 'marked';
4
5 export interface RawOutline {
6   id: string;
7   created: number;
8   name: string;
9   tree: OutlineTree;
10   contentNodes: Record<string, OutlineNode>
11 }
12
13 export interface OutlineTree {
14   id: string;
15   children: OutlineTree[]
16   collapsed: boolean;
17 }
18
19 export interface OutlineNode {
20   id: string;
21   created: number;
22   type: 'text',
23   content: string,
24 };
25
26 export class Outline {
27   data: RawOutline;
28
29   constructor(outlineData: RawOutline) {
30     this.data = outlineData;
31   }
32
33   findNodeInTree(root: OutlineTree, id: string, action: (item: OutlineTree, parent: OutlineTree) => void, runState: boolean = false) {
34     let run = runState;
35     if(run) {
36       return;
37     }
38     _.each(root.children, (childNode, idx) => {
39       if(childNode.id === id) {
40         action(childNode, root);
41         run = true;
42         return false;
43       }
44       else if(childNode.children) {
45         this.findNodeInTree(childNode, id, action, run);
46       }
47     });
48   }
49
50   fold(nodeId: string) {
51     this.findNodeInTree(this.data.tree, nodeId, item => {
52       item.collapsed = true;
53     });
54   }
55
56   unfold(nodeId: string) {
57     this.findNodeInTree(this.data.tree, nodeId, item => {
58       item.collapsed = true;
59     });
60   }
61
62   flattenOutlineTreeChildren(tree: OutlineTree): string[] {
63     return tree.children.map(node => node.id);
64   }
65
66   liftNodeToParent(nodeId: string) {
67     let run = false;
68     let targetNode: OutlineTree, parentNode: OutlineTree;
69     this.findNodeInTree(this.data.tree, nodeId, (tNode, pNode) => {
70       targetNode = tNode;
71       this.findNodeInTree(this.data.tree, pNode.id, (originalParentNode, newParentNode) => {;
72           if(run) {
73             return;
74           }
75           parentNode = newParentNode;
76           run = true;
77
78           const flatId = newParentNode.children.map(n => n.id);
79
80           const originalNodePosition = originalParentNode.children.map(n => n.id).indexOf(targetNode.id);
81           const newNodePosition = flatId.indexOf(originalParentNode.id);
82
83           originalParentNode.children.splice(originalNodePosition, 1);
84
85           newParentNode.children.splice(newNodePosition + 1, 0, targetNode);
86       });
87     });
88
89     return {
90       targetNode,
91       parentNode
92     }
93   }
94   
95   lowerNodeToChild(nodeId: string) {
96     let run = false;
97     // find the previous sibling
98     // make this node a child of the sibling node
99     let targetNode: OutlineTree, newParentNode: OutlineTree, oldParentNode: OutlineTree;
100     this.findNodeInTree(this.data.tree, nodeId, (tNode, pNode) => {
101       if(run) {
102         return;
103       }
104       run  = true;
105       targetNode = tNode;
106       
107       let idList = pNode.children.map(n => n.id);
108       // there are no other siblings so we can't do anything
109       if(idList.length === 1) {
110         return;
111       }
112
113       const position = idList.indexOf(targetNode.id);
114       const prevSiblingPosition = position - 1;
115
116       pNode.children[prevSiblingPosition].children.splice(0, 0, targetNode);
117       pNode.children.splice(position, 1);
118
119       newParentNode = pNode.children[prevSiblingPosition];
120       oldParentNode = pNode;
121     });
122     return {
123       targetNode,
124       newParentNode,
125       oldParentNode
126     }
127   }
128
129   swapNodeWithNextSibling(nodeId: string) {
130     let targetNode: OutlineTree, parentNode: OutlineTree;
131     this.findNodeInTree(this.data.tree, nodeId, (tNode, pNode) => {
132         targetNode = tNode;
133         parentNode = pNode;
134         const flatId = parentNode.children.map(n => n.id);
135         const nodePosition = flatId.indexOf(targetNode.id);
136
137         if(nodePosition === (flatId.length - 1)) {
138           // this is the last node in the list, there's nothing to swap
139           return;
140         }
141
142         // remove the node from this point, and push it one later
143         parentNode.children.splice(nodePosition, 1);
144         parentNode.children.splice(nodePosition + 1, 0, targetNode);
145     });
146
147     return {
148       targetNode,
149       parentNode
150     }
151   }
152
153   swapNodeWithPreviousSibling(nodeId: string) {
154     let targetNode: OutlineTree, parentNode: OutlineTree;
155     this.findNodeInTree(this.data.tree, nodeId, (tNode, pNode) => {
156         targetNode = tNode;
157         parentNode = pNode;
158         const flatId = parentNode.children.map(n => n.id);
159         const nodePosition = flatId.indexOf(targetNode.id);
160
161         if(nodePosition === 0) {
162           // this is the first node in the list, there's nothing to swap
163           return;
164         }
165
166         // remove the node from this point, and push it one later
167         parentNode.children.splice(nodePosition, 1);
168         parentNode.children.splice(nodePosition - 1, 0, targetNode);
169     });
170
171     return {
172       targetNode,
173       parentNode
174     }
175   }
176
177   createSiblingNode(targetNode: string, nodeData?: OutlineNode) {
178     const outlineNode: OutlineNode = nodeData || {
179       id: uuid(),
180       created: Date.now(),
181       type: 'text',
182       content: '---'
183     };
184
185     this.data.contentNodes[outlineNode.id] = outlineNode;
186
187     let parentNode: OutlineTree;
188
189     this.findNodeInTree(this.data.tree, targetNode, (node, parent) => {
190       const position = parent.children.map(n => n.id).indexOf(targetNode);
191       parent.children.splice(position + 1, 0, {
192         id: outlineNode.id,
193         collapsed: false,
194         children: []
195       });
196
197       parentNode = parent;
198     });
199
200     return {
201       node: outlineNode,
202       parentNode
203     }
204   }
205
206   createChildNode(currentNode: string, nodeId?: string) {
207     const node: OutlineNode = nodeId ? this.data.contentNodes[nodeId] :
208     {
209       id: uuid(),
210       created: Date.now(),
211       type: 'text',
212       content: '---'
213     };
214
215     if(!nodeId) {
216       this.data.contentNodes[node.id] = node;
217     }
218
219     let parentNode: OutlineTree;
220
221     this.findNodeInTree(this.data.tree, currentNode, (foundNode, parent) => {
222       foundNode.children.unshift({
223         id: node.id,
224         children: [],
225         collapsed: false
226       });
227
228       parentNode = foundNode;
229     });
230
231     return {
232       node,
233       parentNode
234     }
235   }
236
237   removeNode(nodeId: string) {
238     let run = false;
239     let removedNode: OutlineTree, parentNode: OutlineTree;
240     this.findNodeInTree(this.data.tree, nodeId, (tNode, pNode) => {
241       if(run) {
242         return;
243       }
244       run = true;
245       removedNode = tNode;
246       parentNode = pNode;
247
248       let position = parentNode.children.map(n => n.id).indexOf(tNode.id);
249
250       parentNode.children.splice(position, 1);
251     });
252
253     return {
254       removedNode,
255       parentNode
256     }
257   }
258
259   updateContent(id: string, content: string) {
260     if(!this.data.contentNodes[id]) {
261       throw new Error('Invalid node');
262     }
263
264     this.data.contentNodes[id].content = content;
265   }
266
267   renderContent(nodeId: string): string {
268     let node = this.data.contentNodes[nodeId];
269     let content: string;
270     switch(node.type) {
271       case 'text':
272         content = marked.parse(node.content);
273         break;
274       default: 
275         content = node.content;
276         break;
277     }
278
279     return content;
280   }
281
282   renderNode(node: OutlineTree): string {
283     if(node.id === '000000') {
284       return this.render();
285     }
286     const collapse = node.collapsed ? 'collapsed': 'expanded';
287     const content: OutlineNode = this.data.contentNodes[node.id] || {
288       id: node.id,
289       created: Date.now(),
290       type: 'text',
291       content: ''
292     };
293
294     let html = `<div class="node ${collapse}" data-id="${node.id}" id="id-${node.id}">
295     <div class="nodeContent" data-type="${content.type}">
296       ${this.renderContent(node.id)}
297     </div>
298     ${node.children.length ? _.map(node.children, this.renderNode.bind(this)).join("\n") : ''}
299     </div>`
300
301     return html;
302   }
303
304   render() {
305     /*
306      * render starts at the root node and only renders its children. The root 
307      * node only exists as a container around the rest to ensure a standard format
308      * for the tree
309      */
310     return _.map(this.data.tree.children, this.renderNode.bind(this)).join("\n");
311   }
312 }