Files: 36fdd005ea9c7306764cf0040cbb11101156f80a / app / page / blogNew.js
9291 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, Struct, Value, when, resolve } = require('mutant') |
3 | const addSuggest = require('suggest-box') |
4 | const pull = require('pull-stream') |
5 | const marksum = require('markdown-summary') |
6 | const MediumEditor = require('medium-editor').MediumEditor |
7 | const MediumToMD = require('medium-editor-markdown') |
8 | const throttle = require('lodash/throttle') |
9 | // const CustomHtml = require('medium-editor-custom-async') |
10 | |
11 | const DRAFT_LOCATION = 'TicktackBlogNew' |
12 | // NOTE - may want to have multiple drafts in future, this location would then become variable |
13 | |
14 | exports.gives = nest('app.page.blogNew') |
15 | |
16 | exports.needs = nest({ |
17 | 'app.html.sideNav': 'first', |
18 | 'blob.html.input': 'first', |
19 | 'channel.async.suggest': 'first', |
20 | 'drafts.sync.get': 'first', |
21 | 'drafts.sync.set': 'first', |
22 | 'history.sync.push': 'first', |
23 | 'message.html.compose': 'first', |
24 | 'translations.sync.strings': 'first', |
25 | 'sbot.async.addBlob': 'first' |
26 | }) |
27 | |
28 | exports.create = (api) => { |
29 | return nest('app.page.blogNew', blogNew) |
30 | |
31 | function blogNew (location) { |
32 | const strings = api.translations.sync.strings() |
33 | const getChannelSuggestions = api.channel.async.suggest() |
34 | |
35 | const meta = Struct({ |
36 | type: 'blog', |
37 | channel: Value(), |
38 | title: Value(), |
39 | summary: Value(), |
40 | text: Value('') |
41 | }) |
42 | |
43 | const title = h('h1.input', { |
44 | attributes: { |
45 | contenteditable: true, |
46 | 'data-placeholder': strings.blogNew.field.title |
47 | }, |
48 | 'ev-input': updateTitle, |
49 | className: when(meta.title, '', '-empty') |
50 | }) |
51 | function updateTitle (e) { |
52 | if (e.target.childElementCount) { |
53 | // the title h1 is contenteditable, meaning people can paste html elements in here! |
54 | // this is designed to strip down to heading content text |
55 | // - I went with contenteditable because it handles wrapping of long titles, |
56 | // whereas a styled input field just pushes content off the page! |
57 | e.target.innerHTML = e.target.innerText |
58 | } |
59 | meta.title.set(e.target.innerText) |
60 | } |
61 | |
62 | var mediumComposer |
63 | mediumComposer = h('Markdown.editor', { |
64 | }) |
65 | var filesById = {} |
66 | const composer = initialiseDummyComposer({ filesById, meta, api }) |
67 | // NOTE we are bootstrapping off the message.html.compose logic |
68 | // - the mediumComposer feeds content into the composer, which the provides publishing |
69 | // - this works, but should be refactorer after more thought about generalised composer |
70 | // componentes have been done |
71 | |
72 | const channelInput = h('input', { |
73 | 'ev-input': e => meta.channel.set(e.target.value), |
74 | value: meta.channel, |
75 | placeholder: strings.channel |
76 | }) |
77 | |
78 | var page = h('Page -blogNew', [ |
79 | api.app.html.sideNav(location), |
80 | h('div.content', [ |
81 | h('div.container', { 'ev-input': throttledSaveDraft({ composer: mediumComposer, meta, api }) }, [ |
82 | h('div.field -title', title), |
83 | mediumComposer, |
84 | h('div.field -attachment', |
85 | AddFileButton({ api, filesById, meta, composer: mediumComposer }) |
86 | ), |
87 | h('div.field -channel', [ |
88 | h('div.label', strings.channel), |
89 | channelInput |
90 | ]), |
91 | h('div.field -summary', [ |
92 | h('div.label', strings.blogNew.field.summary), |
93 | h('input', { |
94 | 'ev-input': e => meta.summary.set(e.target.value), |
95 | placeholder: strings.blogNew.field.summary |
96 | }) |
97 | ]), |
98 | composer |
99 | ]) |
100 | ]) |
101 | ]) |
102 | |
103 | initialiseMedium({ api, page, el: mediumComposer, meta }) |
104 | initialiseChannelSuggests({ input: channelInput, suggester: getChannelSuggestions, meta }) |
105 | |
106 | loadDrafts({ composer: mediumComposer, title, meta, api }) |
107 | |
108 | return page |
109 | } |
110 | } |
111 | |
112 | function loadDrafts ({ composer, title, meta, api }) { |
113 | var draft = api.drafts.sync.get(DRAFT_LOCATION) |
114 | if (!draft) return |
115 | |
116 | if (draft.title) { |
117 | meta.title.set(draft.title) |
118 | title.innerText = draft.title |
119 | } |
120 | |
121 | if (draft.body) composer.innerHTML = draft.body |
122 | } |
123 | |
124 | function saveDraft ({ composer, meta, api }) { |
125 | const hasBody = composer.innerText.split('\n').join('').replace(/\s/g, '').length > 0 |
126 | |
127 | const draft = { |
128 | title: resolve(meta.title), |
129 | body: hasBody ? composer.innerHTML : null |
130 | } |
131 | api.drafts.sync.set(DRAFT_LOCATION, draft) |
132 | } |
133 | function throttledSaveDraft ({ composer, meta, api }) { |
134 | return throttle(() => saveDraft({ composer, meta, api }), 2000) |
135 | } |
136 | |
137 | function AddFileButton ({ api, filesById, meta, composer }) { |
138 | const fileInput = api.blob.html.input(file => { |
139 | filesById[file.link] = file |
140 | |
141 | const isImage = file.type.match(/^image/) |
142 | |
143 | var content |
144 | |
145 | if (isImage) { |
146 | content = h('img', { |
147 | src: `http://localhost:8989/blobs/get/${encodeURIComponent(file.link)}`, |
148 | alt: file.name |
149 | }) |
150 | } else { |
151 | content = h('a', { href: file.link }, file.name) |
152 | } |
153 | // TODO - insert where the mouse is yo |
154 | var editor = MediumEditor.getEditorFromElement(composer) |
155 | composer.insertBefore( |
156 | h('p', content), |
157 | editor.currentEl || null |
158 | ) |
159 | |
160 | saveDraft({ composer, meta, api }) |
161 | |
162 | console.log('added:', file) |
163 | }) |
164 | |
165 | return fileInput |
166 | } |
167 | |
168 | function initialiseDummyComposer ({ meta, api, filesById }) { |
169 | const strings = api.translations.sync.strings() |
170 | |
171 | return api.message.html.compose( |
172 | { |
173 | meta, |
174 | shrink: false, |
175 | canAttach: false, |
176 | canPreview: false, |
177 | publishString: strings.publishBlog, |
178 | filesById, |
179 | prepublish: function (content, cb) { |
180 | var m = /\!\[[^]+\]\(([^\)]+)\)/.exec(marksum.image(content.text)) |
181 | content.thumbnail = m && m[1] |
182 | // content.summary = marksum.summary(content.text) // TODO Need a summary which doesn't trim the start |
183 | |
184 | var stream = pull.values([content.text]) |
185 | api.sbot.async.addBlob(stream, function (err, hash) { |
186 | if (err) return cb(err) |
187 | if (!hash) throw new Error('missing hash') |
188 | content.blog = hash |
189 | delete content.text |
190 | cb(null, content) |
191 | }) |
192 | } |
193 | }, |
194 | (err, msg) => { |
195 | api.drafts.sync.remove(DRAFT_LOCATION) |
196 | api.history.sync.push(err || { page: 'blogIndex' }) |
197 | } |
198 | ) |
199 | } |
200 | |
201 | function initialiseChannelSuggests ({ input, suggester, meta }) { |
202 | addSuggest(input, (inputText, cb) => { |
203 | inputText = inputText.replace(/^#/, '') |
204 | var suggestions = suggester(inputText) |
205 | .map(s => { |
206 | s.value = s.value.replace(/^#/, '') // strip the defualt # prefix here |
207 | return s |
208 | }) |
209 | .map(s => { |
210 | if (s.subtitle === 'subscribed') { s.subtitle = h('i.fa.fa-heart') } // TODO - translation-friendly subscribed |
211 | return s |
212 | }) |
213 | |
214 | // HACK add the input text if it's not an option already |
215 | if (!suggestions.some(s => s.title === inputText)) { |
216 | suggestions.push({ |
217 | title: inputText, |
218 | subtitle: h('i.fa.fa-plus-circle'), |
219 | value: inputText |
220 | }) |
221 | } |
222 | |
223 | cb(null, suggestions) |
224 | }, {cls: 'PatchSuggest.-channelHorizontal'}) // WARNING hacking suggest-box cls |
225 | |
226 | input.addEventListener('suggestselect', (e) => { |
227 | meta.channel.set(e.detail.value) |
228 | }) |
229 | } |
230 | |
231 | function initialiseMedium ({ api, page, el, meta }) { |
232 | const strings = api.translations.sync.strings() |
233 | const draft = api.drafts.sync.get(DRAFT_LOCATION) || {} |
234 | |
235 | var editor = new MediumEditor(el, { |
236 | placeholder: { |
237 | text: draft.body ? '' : strings.blogNew.actions.writeBlog |
238 | }, |
239 | elementsContainer: page, |
240 | // autoLink: true, |
241 | buttonLabels: 'fontawesome', |
242 | imageDragging: true, |
243 | toolbar: { |
244 | allowMultiParagraphSelection: true, |
245 | buttons: [ |
246 | 'bold', |
247 | 'italic', |
248 | 'anchor', |
249 | { |
250 | name: 'h2', |
251 | contentFA: '<i class="fa fa-header" />', |
252 | classList: ['custom-button-h2'] |
253 | }, |
254 | { |
255 | name: 'h3', |
256 | contentFA: '<i class="fa fa-header" />', |
257 | classList: ['custom-button-h3'] |
258 | }, |
259 | 'quote' |
260 | ], |
261 | diffLeft: 0, |
262 | diffTop: 10, |
263 | firstButtonClass: 'medium-editor-button-first', |
264 | lastButtonClass: 'medium-editor-button-last', |
265 | relativeContainer: null, |
266 | standardizeSelectionStart: false, |
267 | static: false, |
268 | /* options which only apply when static is true */ |
269 | align: 'center', |
270 | sticky: false, |
271 | updateOnEmptySelection: false |
272 | }, |
273 | extensions: { |
274 | markdown: new MediumToMD( |
275 | { |
276 | toMarkdownOptions: { |
277 | converters: [{ |
278 | filter: 'img', |
279 | replacement: (content, node) => { |
280 | var blob = decodeURIComponent(node.src.replace('http://localhost:8989/blobs/get/', '')) |
281 | return `![${node.alt}](${blob})` |
282 | } |
283 | }, { |
284 | filter: 'span', |
285 | replacement: (content, node) => content |
286 | }] |
287 | }, |
288 | events: ['input', 'change', 'DOMNodeInserted'] |
289 | }, |
290 | md => meta.text.set(md) |
291 | ) |
292 | } |
293 | }) |
294 | |
295 | editor.on(el, 'keyup', setCurrentEl) |
296 | editor.on(el, 'click', setCurrentEl) |
297 | |
298 | function setCurrentEl (ev) { |
299 | var sel = window.getSelection() |
300 | var container = sel.getRangeAt(0).commonAncestorContainer |
301 | editor.currentEl = container.textContent === '' // NOTE this could be a brittle check |
302 | ? container |
303 | : container.parentElement |
304 | } |
305 | |
306 | return editor |
307 | } |
308 |
Built with git-ssb-web