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