bug: only account for if the top of an elemnet is out of view
[apps/outliner/.git] / src / client.ts
1 import { Outline, RawOutline } from './outline';
2 import { Cursor } from './cursor';
3 import keyboardJS from 'keyboardjs';
4 import * as rawOutline from './test-data.json';
5 import {showHelp} from 'help';
6 import { Search } from './search';
7
8 let outlineData = rawOutline;
9 if(localStorage.getItem('activeOutline')) {
10   const outlineId = localStorage.getItem('activeOutline');
11   outlineData = JSON.parse(localStorage.getItem(outlineId));
12 }
13
14 const state = new Map<string, any>();
15 const outline = new Outline(outlineData as unknown as RawOutline);
16 outliner().innerHTML = outline.render();
17
18 const cursor = new Cursor();
19 // place the cursor at the top!
20 cursor.set('.node');
21
22 const search = new Search();
23
24 function outliner() {
25   return document.querySelector('#outliner');
26 }
27
28 document.getElementById('display-help').addEventListener('click', e => {
29   e.preventDefault();
30   e.stopPropagation();
31
32   showHelp();
33 });
34
35 // move down
36 keyboardJS.withContext('navigation', () => {
37   keyboardJS.bind('j', e => {
38     // move cursor down
39     // if shift key is held, swap the node with its next sibling
40     const sibling = cursor.get().nextElementSibling;
41
42     if(sibling) {
43       if(e.shiftKey) {
44         // swap this node with its previous sibling
45         const res = outline.swapNodeWithNextSibling(cursor.getIdOfNode());
46         const html = outline.renderNode(res.parentNode);
47
48         if(res.parentNode.id === '000000') {
49           cursor.get().parentElement.innerHTML = html;
50         }
51         else {
52           cursor.get().parentElement.outerHTML = html;
53         }
54
55         cursor.set(`#id-${res.targetNode.id}`);
56         save();
57       }
58       else {
59         cursor.set(`#id-${sibling.getAttribute('data-id')}`);
60       }
61     }
62   });
63
64
65   keyboardJS.bind('shift + /', e => {
66     showHelp();
67   });
68
69   keyboardJS.bind('k', e => {
70     // move cursor up
71     // if shift key is held, swap the node with its previous sibling
72     const sibling = cursor.get().previousElementSibling;
73
74     if(sibling && !sibling.classList.contains('nodeContent')) {
75       if(e.shiftKey) {
76         // swap this node with its previous sibling
77         const res = outline.swapNodeWithPreviousSibling(cursor.getIdOfNode());
78         // re-render the parent node and display that!
79         const html = outline.renderNode(res.parentNode);
80
81         if(res.parentNode.id === '000000') {
82           cursor.get().parentElement.innerHTML = html;
83         }
84         else {
85           cursor.get().parentElement.outerHTML = html;
86         }
87
88         cursor.set(`#id-${res.targetNode.id}`);
89         save();
90       }
91       else {
92         cursor.set(`#id-${sibling.getAttribute('data-id')}`);
93       }
94     }
95   });
96
97   keyboardJS.bind('l', e => {
98     // if the node is collapsed, we can't go into its children
99     if(cursor.isNodeCollapsed()) {
100       return;
101     }
102     if(e.shiftKey) {
103       const res = outline.lowerNodeToChild(cursor.getIdOfNode());
104       const html = outline.renderNode(res.oldParentNode);
105
106       if(res.oldParentNode.id === '000000') {
107         cursor.get().parentElement.innerHTML = html;
108       }
109       else {
110         cursor.get().parentElement.outerHTML = html;
111       }
112       cursor.set(`#id-${res.targetNode.id}`);
113     }
114     else {
115       const children = cursor.get().querySelector('.node');
116       if(children) {
117         cursor.set(`#id-${children.getAttribute('data-id')}`);
118       }
119     }
120   });
121
122   keyboardJS.bind('h', e => {
123     const parent = cursor.get().parentElement;
124     if(parent && parent.classList.contains('node')) {
125       if(e.shiftKey) {
126         if(outline.data.tree.children.map(n => n.id).includes(cursor.getIdOfNode())) {
127           // if this is a top level item, we can't elevate any further
128           return;
129         }
130         const res = outline.liftNodeToParent(cursor.getIdOfNode());
131
132         const html = outline.renderNode(res.parentNode);
133
134         if(res.parentNode.id === '000000') {
135           cursor.get().parentElement.parentElement.innerHTML = html;
136         }
137         else {
138           cursor.get().parentElement.parentElement.outerHTML = html;
139         }
140
141         cursor.set(`#id-${res.targetNode.id}`);
142         save();
143       }
144       else {
145         cursor.set(`#id-${parent.getAttribute('data-id')}`);
146       }
147     }
148   });
149
150   keyboardJS.bind('z', e => {
151     // toggle collapse
152     if(cursor.isNodeExpanded()) {
153       cursor.collapse();
154       outline.fold(cursor.getIdOfNode());
155     }
156     else if(cursor.isNodeCollapsed()) {
157       cursor.expand();
158       outline.unfold(cursor.getIdOfNode());
159     }
160     save();
161   });
162
163   keyboardJS.bind('shift + 4', e => {
164     e.preventDefault();
165     // switch to editing mode
166     cursor.get().classList.add('hidden-cursor');
167     const contentNode = cursor.get().querySelector('.nodeContent') as HTMLElement;
168
169     // swap the content to the default!
170     contentNode.innerHTML = outline.data.contentNodes[cursor.getIdOfNode()].content;
171     contentNode.contentEditable = "true";
172
173     const range = document.createRange();
174     range.selectNodeContents(contentNode);
175     range.collapse(false);
176
177     const selection = window.getSelection();
178     selection.removeAllRanges();
179     selection.addRange(range);
180
181     contentNode.focus();
182     keyboardJS.setContext('editing');
183   });
184
185   keyboardJS.bind('i', e => {
186     e.preventDefault();
187     // switch to editing mode
188     cursor.get().classList.add('hidden-cursor');
189     const contentNode = cursor.get().querySelector('.nodeContent') as HTMLElement;
190
191     // swap the content to the default!
192     contentNode.innerHTML = outline.data.contentNodes[cursor.getIdOfNode()].content;
193     contentNode.contentEditable = "true";
194     contentNode.focus();
195     keyboardJS.setContext('editing');
196   });
197
198   keyboardJS.bind('shift + x', e => {
199     e.preventDefault();
200     // toggle "strikethrough" of node
201     cursor.get().classList.toggle('strikethrough');
202     outline.data.contentNodes[cursor.getIdOfNode()].strikethrough = cursor.get().classList.contains('strikethrough');
203     save();
204   });
205
206   keyboardJS.bind('tab', e => {
207     e.preventDefault();
208
209     const res = outline.createChildNode(cursor.getIdOfNode());
210     const html = outline.renderNode(res.parentNode);
211
212     cursor.get().outerHTML = html;
213
214     cursor.set(`#id-${res.node.id}`);
215     save();
216   });
217   
218   keyboardJS.bind('enter', e => {
219     // create a new node as a sibling of the selected node
220     if(e.shiftKey) {
221       return;
222     }
223     e.preventDefault();
224     e.preventRepeat();
225
226     const res = outline.createSiblingNode(cursor.getIdOfNode());
227
228     const html = outline.renderNode(res.parentNode);
229     if(res.parentNode.id === '000000') {
230       cursor.get().parentElement.innerHTML = html;
231     }
232     else {
233       cursor.get().parentElement.outerHTML = html;
234     }
235
236     cursor.set(`#id-${res.node.id}`);
237     save();
238   });
239
240   keyboardJS.bind('d', e => {
241     // deleting a node requires d + shift
242     if(!e.shiftKey) {
243       return;
244     }
245
246     const res = outline.removeNode(cursor.getIdOfNode());
247     const html = outline.renderNode(res.parentNode);
248     // the previous sibling!
249     const prevSibling = cursor.get().previousElementSibling;
250     const nextSibling = cursor.get().nextElementSibling;
251     if(res.parentNode.id === '000000') {
252       cursor.get().parentElement.innerHTML = html;
253     }
254     else {
255       cursor.get().parentElement.outerHTML = html;
256     }
257
258     if(prevSibling.getAttribute('data-id')) {
259       cursor.set(`#id-${prevSibling.getAttribute('data-id')}`);
260     }
261     else if(nextSibling.getAttribute('data-id')) {
262       cursor.set(`#id-${nextSibling.getAttribute('data-id')}`);
263     }
264     else {
265       console.log(res.parentNode.id);
266       cursor.set(`#id-${res.parentNode.id}`);
267     }
268
269     save();
270   });
271 });
272
273 keyboardJS.withContext('editing', () => {
274   keyboardJS.bind(['esc', 'enter'], e => {
275     cursor.get().classList.remove('hidden-cursor');
276
277     const contentNode = cursor.get().querySelector('.nodeContent') as HTMLElement;
278
279     contentNode.contentEditable = "false";
280     contentNode.blur();
281     keyboardJS.setContext('navigation');
282
283     outline.updateContent(cursor.getIdOfNode(), contentNode.innerHTML.trim());
284     // re-render this node!
285     contentNode.innerHTML = outline.renderContent(cursor.getIdOfNode());
286     save();
287   });
288 });
289
290 keyboardJS.setContext('navigation');
291
292 search.createIndex({
293   id: "string",
294   created: "number",
295   type: "string",
296   content: "string",
297   strikethrough: "boolean"
298 }).then(async () => {
299   await search.indexBatch(outline.data.contentNodes);
300   search.bindEvents();
301 });
302
303 function recursivelyExpand(start: HTMLElement) {
304   if(start.classList.contains('node')) {
305     if(start.classList.contains('collapsed')) {
306       start.classList.remove('collapsed');
307       start.classList.add('expanded');
308       outline.unfold(start.getAttribute('data-id'));
309     }
310
311     if(start.parentElement) {
312       recursivelyExpand(start.parentElement)
313     }
314   }
315 }
316
317 search.onTermSelection = (docId: string) => {
318   // if any parent element in the chain to this node
319   // are collapsed, we want to make sure we expand them
320
321   recursivelyExpand(document.getElementById(`id-${docId}`).parentElement);
322   cursor.set(`#id-${docId}`);
323
324   save();
325 };
326
327 function saveImmediate() {
328   localStorage.setItem(outline.data.id, JSON.stringify(outline.data));
329   localStorage.setItem('activeOutline', outline.data.id);
330   console.log('saved...', outline.data);
331   state.delete('saveTimeout');
332 }
333
334 function save() {
335   if(!state.has('saveTimeout')) {
336     state.set('saveTimeout', setTimeout(saveImmediate, 2000));
337   }
338 }
339
340
341 save();