Files: 43af34efb145e2705ff168ae3e6f3cc6832314ac / tree-view.js
10064 bytesRaw
1 | // mutant |
2 | const h = require('mutant/html-element') |
3 | const MutantMap = require('mutant/map') |
4 | const Dict = require('mutant/dict') |
5 | const Value = require('mutant/value') |
6 | const Struct = require('mutant/struct') |
7 | const MutantArray = require('mutant/array') |
8 | const computed = require('mutant/computed') |
9 | const when = require('mutant/when') |
10 | const send = require('mutant/send') |
11 | const resolve = require('mutant/resolve') |
12 | // -- |
13 | // |
14 | const pull = require('pull-stream') |
15 | const ref = require('ssb-ref') |
16 | |
17 | const updates = require('./update-stream')() // no trusted keys |
18 | const {updateObservableMessages} = require('./message-cache') |
19 | const {isDraft} = require('./util') |
20 | const config = require('./cms-config') |
21 | |
22 | module.exports = function(ssb, drafts, root, trusted_keys) { |
23 | |
24 | let selection = Value() |
25 | let ready = Value(false) |
26 | |
27 | function addNode(node) { |
28 | let content = node.msg().content |
29 | let value = { |
30 | content: { |
31 | root: content.root || node.id, |
32 | branch: node.id, |
33 | type: 'node' |
34 | } |
35 | } |
36 | let json = JSON.stringify(value, null, 2) |
37 | drafts.create(JSON.stringify(value, null, 2), node.id, null, null, (err, key)=>{ |
38 | if (err) throw err |
39 | }) |
40 | } |
41 | |
42 | function cloneNode(node) { |
43 | let content = node.msg().content |
44 | let json = JSON.stringify(node.msg(), null, 2) |
45 | drafts.create(json, content.branch, null, null, (err, key)=>{ |
46 | if (err) throw err |
47 | }) |
48 | } |
49 | |
50 | function discardDraft(node) { |
51 | drafts.remove(node.id, (err)=>{ |
52 | if (err) throw err |
53 | if (selection() === node.id) { |
54 | document.location.hash = '' |
55 | } |
56 | }) |
57 | } |
58 | |
59 | function selectId(id) { |
60 | //console.log(this, id) |
61 | // from https://stackoverflow.com/questions/11451353/how-to-select-the-text-of-a-span-on-click |
62 | let selection = window.getSelection() |
63 | let range = document.createRange() |
64 | this.innerText = id |
65 | range.selectNodeContents(this) |
66 | selection.removeAllRanges() |
67 | selection.addRange(range) |
68 | } |
69 | |
70 | function html(node) { |
71 | |
72 | function _click(handler, args) { |
73 | return { 'ev-click': function(e) { handler.apply(this, args) }} |
74 | } |
75 | |
76 | return h('li', [ |
77 | h('div', { |
78 | classList: computed([node.open, isDraft(node.id)], (open, draft) => { |
79 | let l = ['branch'] |
80 | if (open) l.push('open') |
81 | if (draft) l.push('draft') |
82 | return l |
83 | }) |
84 | }, [ |
85 | h('.branch-header', [ |
86 | h('span.triangle', { |
87 | 'ev-click': send(()=>{ |
88 | if (isDraft(node.id)) return |
89 | node.open.set(!node.open()) |
90 | }) |
91 | }), |
92 | h('span.msgNode', [ // TODO: do we need this? |
93 | h('span.type-key', [ |
94 | h('span.type', node.type), |
95 | h('a', { |
96 | classList: selection() === node.id ? ['node', 'selected'] : ['node'], |
97 | href: `#${node.id}` |
98 | }, |
99 | h('span.name', node.label) |
100 | ) |
101 | ]), |
102 | when(node.unsaved, h('span', {title: 'draft'}, '✎')), |
103 | when(node.forked, h('span', {title: 'conflicting updates, plese merge'}, '⑃')), |
104 | when(computed([node.msg], v => trusted_keys.includes(v.author)), h('span.trusted', {title: 'Signed-off'})), |
105 | //when(node.incomplete, h('span', {title: 'incomplete history'}, '⚠')), |
106 | h('span.buttons', [ |
107 | h('button.id', { |
108 | 'ev-click': function(e) { selectId.apply(this, [node.id]) }, |
109 | 'ev-blur': function(e) { this.innerText = 'id' } |
110 | }, 'id' ), |
111 | when(node.open, h('button.add', _click(addNode, [node]), 'add' )), |
112 | when(!isDraft(node.id), h('button.clone', _click(cloneNode, [node]), 'clone' )), |
113 | when(isDraft(node.id), h('button.discard', _click(discardDraft, [node]), 'discard' )) |
114 | ]) |
115 | ]) |
116 | ]), |
117 | when(node.open, h('ul', MutantMap(node.children, html))) |
118 | ]) |
119 | ]) |
120 | } |
121 | |
122 | function streamChildren(root, mutantArray, syncedCb) { |
123 | let drain |
124 | pull( |
125 | ssb.cms.branches(root, {live: true, sync: true}), |
126 | updates({ |
127 | allowUntrusted: true, |
128 | live: true, |
129 | sync: true, |
130 | bufferUntilSync: true |
131 | }), |
132 | pull.filter( x=>{ |
133 | if (x.sync) syncedCb(null) |
134 | return !x.sync |
135 | }), |
136 | drain = updateObservableMessages(mutantArray, { |
137 | makeObservable: makeNode, |
138 | updateObservable: updateNode |
139 | }) |
140 | ) |
141 | return drain.abort |
142 | } |
143 | |
144 | function updateNode(child, kv) { |
145 | child.msg.set(kv.value) |
146 | child.unsaved.set(kv.unsaved) |
147 | child.forked.set(Object.keys(kv.heads).length > 1) |
148 | child.incomplete.set(kv.tail !== kv.key || kv.queue.length !== 0) |
149 | } |
150 | |
151 | function makeNode(kv) { |
152 | let dict = Dict(kv.value) |
153 | let label = computed([dict], x=>x.content && x.content.name) |
154 | let type = computed([dict], x=>x.content && x.content.type) |
155 | let node = Struct({ |
156 | msg: dict, |
157 | label, |
158 | type, |
159 | open: false, |
160 | unsaved: false, |
161 | loaded: false, |
162 | forked: false, |
163 | incomplete: true, |
164 | children: MutantArray() |
165 | }) |
166 | node.id = kv.key |
167 | let abortStream |
168 | node.open( (isOpen)=> { |
169 | if (isOpen) { |
170 | abortStream = streamChildren(node.id, node.children, ()=>{ |
171 | node.loaded.set(true) |
172 | }) |
173 | } else { |
174 | abortStream() |
175 | node.loaded.set(false) |
176 | node.children.clear() |
177 | } |
178 | }) |
179 | return node |
180 | } |
181 | |
182 | let roots = MutantArray() |
183 | let lis = MutantMap(roots, html) |
184 | let ul = h('ul', lis) |
185 | let treeView = h('.treeView', ul) |
186 | |
187 | function ensureVisible(nodePath, cb) { |
188 | |
189 | function r(children, nodePath, cb) { |
190 | let child = children.find( x=>x.id === nodePath[0]) |
191 | if (!child) return cb(new Error(`tree node not found at path: $(nodePath.join(' -> '))}`)) |
192 | nodePath.shift() |
193 | if (!nodePath.length) return cb(null, child) |
194 | |
195 | if (!child.loaded()) { |
196 | // we need to wait for the children to arrive |
197 | let unsubscribe |
198 | unsubscribe = child.loaded( (isLoaded)=>{ |
199 | if (isLoaded) { |
200 | unsubscribe() |
201 | r(child.children, nodePath, cb) |
202 | } |
203 | }) |
204 | child.open.set(true) |
205 | } else r(child.children, nodePath, cb) |
206 | } |
207 | |
208 | return r(roots, nodePath, cb) |
209 | } |
210 | |
211 | |
212 | function ancestors(msg, result, cb) { |
213 | let branch = msg.content && msg.content.branch |
214 | if (branch === config.sbot.cms.root) return cb(null, result) |
215 | if (branch) { |
216 | result.unshift(branch) |
217 | ssb.cms.getMessageOrDraft(branch, (err, msg)=>{ |
218 | if (err) return cb(err) |
219 | ancestors(msg, result, cb) |
220 | }) |
221 | } else cb(null, result) |
222 | } |
223 | |
224 | selection( (id)=>{ |
225 | if (!id) return |
226 | // TODO: fastpath for when the TreeNode is already rendered |
227 | ssb.cms.getMessageOrDraft(id, (err, msg) => { |
228 | if (err) return console.error(err) |
229 | let treeNodeId = msg.content && msg.content.revisionRoot || id |
230 | ancestors(msg, [], (err, nodePath)=>{ |
231 | nodePath.push(treeNodeId) |
232 | ensureVisible(nodePath, ()=>{ |
233 | treeView.querySelectorAll('.treeView .selected').forEach( el => el.classList.remove('selected') ) |
234 | let el = treeView.querySelector(`a.node[href="#${id}"]`) |
235 | if (el) el.classList.add('selected') |
236 | }) |
237 | }) |
238 | }) |
239 | }) |
240 | |
241 | streamChildren(root, roots, (err)=>{ |
242 | if (err) throw err |
243 | ready.set(true) |
244 | }) |
245 | |
246 | function findNode(key, children) { |
247 | let node = children.find( x=>x.id === key ) |
248 | if (node) return node |
249 | children.find( x=>{ |
250 | return node = findNode(key, x.children) |
251 | }) |
252 | return node |
253 | } |
254 | |
255 | treeView.selection = selection |
256 | treeView.ready = ready |
257 | treeView.update = function(key, value) { |
258 | let node = findNode(key, roots) |
259 | if (node) node.msg.set(value) |
260 | } |
261 | return treeView |
262 | } |
263 | |
264 | module.exports.css = ()=> ` |
265 | .branch.open>ul { |
266 | margin: 0; |
267 | padding: 0; |
268 | //list-style: none; |
269 | padding-left: 1em; |
270 | } |
271 | |
272 | .branch>.branch-header>.triangle::before { |
273 | content: '▶'; |
274 | color: #555; |
275 | display: inline-block; |
276 | width: 1em; |
277 | font-size: .7em; |
278 | margin-right: .5em; |
279 | cursor: zoom-in; |
280 | } |
281 | |
282 | .branch.open>.branch-header>.triangle::before { |
283 | cursor: zoom-out; |
284 | content: '▼'; |
285 | } |
286 | |
287 | |
288 | |
289 | ul { |
290 | list-style: none; |
291 | } |
292 | .treeView>ul { |
293 | padding-left: .5em; |
294 | } |
295 | span.key { |
296 | color: #222; |
297 | font-weight: bold; |
298 | margin-right: .2em; |
299 | } |
300 | .branch { |
301 | white-space: nowrap; |
302 | } |
303 | .branch-header { |
304 | display: flex; |
305 | flex-wrap: nowrap; |
306 | } |
307 | .branch-header>span.key { |
308 | flex-grow: 1; |
309 | display: inline-flex; |
310 | flex-wrap: nowrap; |
311 | } |
312 | |
313 | .msgNode { |
314 | flex-grow: 1; |
315 | display: inline-flex; |
316 | flex-wrap: nowrap; |
317 | justify-content: space-between; |
318 | } |
319 | |
320 | .branch-header .buttons { |
321 | flex-grow: 1; |
322 | display: inline-flex; |
323 | flex-wrap: nowrap; |
324 | justify-content: flex-end; |
325 | } |
326 | |
327 | .branch>.branch-header button.add { |
328 | display: none; |
329 | } |
330 | .branch.open>.branch-header button.add { |
331 | display: inline-block; |
332 | } |
333 | |
334 | .branch-header { |
335 | background: #ddd; |
336 | border-bottom: 1px solid #eee; |
337 | border-top-left-radius: 8px; |
338 | padding-left: .3em; |
339 | } |
340 | .draft .branch-header { |
341 | background: #dedfb5; |
342 | } |
343 | .branch-header:hover { |
344 | background: #ccc; |
345 | } |
346 | |
347 | .branch-header button { |
348 | background: transparent; |
349 | border: none; |
350 | border-radius: 0; |
351 | color: #777; |
352 | padding: 0 .4em; |
353 | } |
354 | |
355 | button.id { |
356 | max-width: 4em; |
357 | overflow: hidden; |
358 | } |
359 | |
360 | .branch-header button:hover { |
361 | border-top: 1px solid #ccc; |
362 | color: #eee; |
363 | border-bottom: 1px solid #aaa; |
364 | } |
365 | |
366 | a.node { |
367 | color: #dde; |
368 | text-decoration: none; |
369 | margin-left: .2em; |
370 | } |
371 | a.node.draft { |
372 | color: red; |
373 | font-style: italic; |
374 | } |
375 | |
376 | a.node>span.name { |
377 | color: #161438; |
378 | padding: 0px 4px; |
379 | background: #babace; |
380 | } |
381 | |
382 | a.node>span.name:hover { |
383 | color: #0f0d25; |
384 | background: #9f9fb1; |
385 | } |
386 | .node.selected>span.name, |
387 | .node.selected>span.name:hover { |
388 | color: #111110; |
389 | background: #b39254; |
390 | } |
391 | |
392 | a.node>span:hover { |
393 | background-color: #226; |
394 | } |
395 | .node.selected>span { |
396 | color: black; |
397 | background: yellow; |
398 | } |
399 | ` |
400 |
Built with git-ssb-web