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