]> BookStack Code Mirror - bookstack/blob - resources/js/editor/menu/menu.js
Added horizonal rule insert
[bookstack] / resources / js / editor / menu / menu.js
1 /**
2  * This file originates from https://github.com/ProseMirror/prosemirror-menu
3  * and is hence subject to the MIT license found here:
4  * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
5  * @copyright Marijn Haverbeke and others
6  */
7
8 import crel from "crelt"
9 import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
10 import {undo, redo} from "prosemirror-history"
11 import {setBlockAttr, insertBlockBefore} from "../commands";
12
13 import {getIcon, icons} from "./icons"
14
15 const prefix = "ProseMirror-menu"
16
17 // ::- An icon or label that, when clicked, executes a command.
18 export class MenuItem {
19   // :: (MenuItemSpec)
20   constructor(spec) {
21     // :: MenuItemSpec
22     // The spec used to create the menu item.
23     this.spec = spec
24   }
25
26   // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
27   // Renders the icon according to its [display
28   // spec](#menu.MenuItemSpec.display), and adds an event handler which
29   // executes the command when the representation is clicked.
30   render(view) {
31     let spec = this.spec
32     let dom = spec.render ? spec.render(view)
33         : spec.icon ? getIcon(spec.icon)
34         : spec.label ? crel("div", null, translate(view, spec.label))
35         : null
36     if (!dom) throw new RangeError("MenuItem without icon or label property")
37     if (spec.title) {
38       const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
39       dom.setAttribute("title", translate(view, title))
40     }
41     if (spec.class) dom.classList.add(spec.class)
42     if (spec.css) dom.style.cssText += spec.css
43
44     dom.addEventListener("mousedown", e => {
45       e.preventDefault()
46       if (!dom.classList.contains(prefix + "-disabled"))
47         spec.run(view.state, view.dispatch, view, e)
48     })
49
50     function update(state) {
51       if (spec.select) {
52         let selected = spec.select(state)
53         dom.style.display = selected ? "" : "none"
54         if (!selected) return false
55       }
56       let enabled = true
57       if (spec.enable) {
58         enabled = spec.enable(state) || false
59         setClass(dom, prefix + "-disabled", !enabled)
60       }
61       if (spec.active) {
62         let active = enabled && spec.active(state) || false
63         setClass(dom, prefix + "-active", active)
64       }
65       return true
66     }
67
68     return {dom, update}
69   }
70 }
71
72 function translate(view, text) {
73   return view._props.translate ? view._props.translate(text) : text
74 }
75
76 // MenuItemSpec:: interface
77 // The configuration object passed to the `MenuItem` constructor.
78 //
79 //   run:: (EditorState, (Transaction), EditorView, dom.Event)
80 //   The function to execute when the menu item is activated.
81 //
82 //   select:: ?(EditorState) → bool
83 //   Optional function that is used to determine whether the item is
84 //   appropriate at the moment. Deselected items will be hidden.
85 //
86 //   enable:: ?(EditorState) → bool
87 //   Function that is used to determine if the item is enabled. If
88 //   given and returning false, the item will be given a disabled
89 //   styling.
90 //
91 //   active:: ?(EditorState) → bool
92 //   A predicate function to determine whether the item is 'active' (for
93 //   example, the item for toggling the strong mark might be active then
94 //   the cursor is in strong text).
95 //
96 //   render:: ?(EditorView) → dom.Node
97 //   A function that renders the item. You must provide either this,
98 //   [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label).
99 //
100 //   icon:: ?Object
101 //   Describes an icon to show for this item. The object may specify
102 //   an SVG icon, in which case its `path` property should be an [SVG
103 //   path
104 //   spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d),
105 //   and `width` and `height` should provide the viewbox in which that
106 //   path exists. Alternatively, it may have a `text` property
107 //   specifying a string of text that makes up the icon, with an
108 //   optional `css` property giving additional CSS styling for the
109 //   text. _Or_ it may contain `dom` property containing a DOM node.
110 //
111 //   label:: ?string
112 //   Makes the item show up as a text label. Mostly useful for items
113 //   wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object
114 //   should have a `label` property providing the text to display.
115 //
116 //   title:: ?union<string, (EditorState) → string>
117 //   Defines DOM title (mouseover) text for the item.
118 //
119 //   class:: ?string
120 //   Optionally adds a CSS class to the item's DOM representation.
121 //
122 //   css:: ?string
123 //   Optionally adds a string of inline CSS to the item's DOM
124 //   representation.
125
126 let lastMenuEvent = {time: 0, node: null}
127 function markMenuEvent(e) {
128   lastMenuEvent.time = Date.now()
129   lastMenuEvent.node = e.target
130 }
131 function isMenuEvent(wrapper) {
132   return Date.now() - 100 < lastMenuEvent.time &&
133     lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
134 }
135
136 // ::- A drop-down menu, displayed as a label with a downwards-pointing
137 // triangle to the right of it.
138 export class Dropdown {
139   // :: ([MenuElement], ?Object)
140   // Create a dropdown wrapping the elements. Options may include
141   // the following properties:
142   //
143   // **`label`**`: string`
144   //   : The label to show on the drop-down control.
145   //
146   // **`title`**`: string`
147   //   : Sets the
148   //     [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
149   //     attribute given to the menu control.
150   //
151   // **`class`**`: string`
152   //   : When given, adds an extra CSS class to the menu control.
153   //
154   // **`css`**`: string`
155   //   : When given, adds an extra set of CSS styles to the menu control.
156   constructor(content, options) {
157     this.options = options || {}
158     this.content = Array.isArray(content) ? content : [content]
159   }
160
161   // :: (EditorView) → {dom: dom.Node, update: (EditorState)}
162   // Render the dropdown menu and sub-items.
163   render(view) {
164     let content = renderDropdownItems(this.content, view)
165
166     let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""),
167                              style: this.options.css},
168                      translate(view, this.options.label))
169     if (this.options.title) label.setAttribute("title", translate(view, this.options.title))
170     let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label)
171     let open = null, listeningOnClose = null
172     let close = () => {
173       if (open && open.close()) {
174         open = null
175         window.removeEventListener("mousedown", listeningOnClose)
176       }
177     }
178     label.addEventListener("mousedown", e => {
179       e.preventDefault()
180       markMenuEvent(e)
181       if (open) {
182         close()
183       } else {
184         open = this.expand(wrap, content.dom)
185         window.addEventListener("mousedown", listeningOnClose = () => {
186           if (!isMenuEvent(wrap)) close()
187         })
188       }
189     })
190
191     function update(state) {
192       let inner = content.update(state)
193       wrap.style.display = inner ? "" : "none"
194       return inner
195     }
196
197     return {dom: wrap, update}
198   }
199
200   expand(dom, items) {
201     let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
202
203     let done = false
204     function close() {
205       if (done) return
206       done = true
207       dom.removeChild(menuDOM)
208       return true
209     }
210     dom.appendChild(menuDOM)
211     return {close, node: menuDOM}
212   }
213 }
214
215 function renderDropdownItems(items, view) {
216   let rendered = [], updates = []
217   for (let i = 0; i < items.length; i++) {
218     let {dom, update} = items[i].render(view)
219     rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom))
220     updates.push(update)
221   }
222   return {dom: rendered, update: combineUpdates(updates, rendered)}
223 }
224
225 function combineUpdates(updates, nodes) {
226   return state => {
227     let something = false
228     for (let i = 0; i < updates.length; i++) {
229       let up = updates[i](state)
230       nodes[i].style.display = up ? "" : "none"
231       if (up) something = true
232     }
233     return something
234   }
235 }
236
237 // ::- Represents a submenu wrapping a group of elements that start
238 // hidden and expand to the right when hovered over or tapped.
239 export class DropdownSubmenu {
240   // :: ([MenuElement], ?Object)
241   // Creates a submenu for the given group of menu elements. The
242   // following options are recognized:
243   //
244   // **`label`**`: string`
245   //   : The label to show on the submenu.
246   constructor(content, options) {
247     this.options = options || {}
248     this.content = Array.isArray(content) ? content : [content]
249   }
250
251   // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
252   // Renders the submenu.
253   render(view) {
254     const items = renderDropdownItems(this.content, view)
255
256     const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
257     const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
258                    crel("div", {class: prefix + "-submenu"}, items.dom))
259     let listeningOnClose = null
260     handleContent.addEventListener("mousedown", e => {
261       e.preventDefault()
262       markMenuEvent(e)
263       setClass(wrap, prefix + "-submenu-wrap-active")
264       if (!listeningOnClose)
265         window.addEventListener("mousedown", listeningOnClose = () => {
266           if (!isMenuEvent(wrap)) {
267             wrap.classList.remove(prefix + "-submenu-wrap-active")
268             window.removeEventListener("mousedown", listeningOnClose)
269             listeningOnClose = null
270           }
271         })
272     })
273
274     function update(state) {
275       let inner = items.update(state)
276       wrap.style.display = inner ? "" : "none"
277       return inner
278     }
279     return {dom: wrap, update}
280   }
281 }
282
283 // :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
284 // Render the given, possibly nested, array of menu elements into a
285 // document fragment, placing separators between them (and ensuring no
286 // superfluous separators appear when some of the groups turn out to
287 // be empty).
288 export function renderGrouped(view, content) {
289   let result = document.createDocumentFragment()
290   let updates = [], separators = []
291   for (let i = 0; i < content.length; i++) {
292     let items = content[i], localUpdates = [], localNodes = []
293     for (let j = 0; j < items.length; j++) {
294       let {dom, update} = items[j].render(view)
295       let span = crel("span", {class: prefix + "item"}, dom)
296       result.appendChild(span)
297       localNodes.push(span)
298       localUpdates.push(update)
299     }
300     if (localUpdates.length) {
301       updates.push(combineUpdates(localUpdates, localNodes))
302       if (i < content.length - 1)
303         separators.push(result.appendChild(separator()))
304     }
305   }
306
307   function update(state) {
308     let something = false, needSep = false
309     for (let i = 0; i < updates.length; i++) {
310       let hasContent = updates[i](state)
311       if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
312       needSep = hasContent
313       if (hasContent) something = true
314     }
315     return something
316   }
317   return {dom: result, update}
318 }
319
320 function separator() {
321   return crel("span", {class: prefix + "separator"})
322 }
323
324
325 // :: MenuItem
326 // Menu item for the `joinUp` command.
327 export const joinUpItem = new MenuItem({
328   title: "Join with above block",
329   run: joinUp,
330   select: state => joinUp(state),
331   icon: icons.join
332 })
333
334 // :: MenuItem
335 // Menu item for the `lift` command.
336 export const liftItem = new MenuItem({
337   title: "Lift out of enclosing block",
338   run: lift,
339   select: state => lift(state),
340   icon: icons.lift
341 })
342
343 // :: MenuItem
344 // Menu item for the `selectParentNode` command.
345 export const selectParentNodeItem = new MenuItem({
346   title: "Select parent node",
347   run: selectParentNode,
348   select: state => selectParentNode(state),
349   icon: icons.selectParentNode
350 })
351
352 // :: MenuItem
353 // Menu item for the `undo` command.
354 export let undoItem = new MenuItem({
355   title: "Undo last change",
356   run: undo,
357   enable: state => undo(state),
358   icon: icons.undo
359 })
360
361 // :: MenuItem
362 // Menu item for the `redo` command.
363 export let redoItem = new MenuItem({
364   title: "Redo last undone change",
365   run: redo,
366   enable: state => redo(state),
367   icon: icons.redo
368 })
369
370 // :: (NodeType, Object) → MenuItem
371 // Build a menu item for wrapping the selection in a given node type.
372 // Adds `run` and `select` properties to the ones present in
373 // `options`. `options.attrs` may be an object or a function.
374 export function wrapItem(nodeType, options) {
375   let passedOptions = {
376     run(state, dispatch) {
377       // FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
378       return wrapIn(nodeType, options.attrs)(state, dispatch)
379     },
380     select(state) {
381       return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
382     }
383   }
384   for (let prop in options) passedOptions[prop] = options[prop]
385   return new MenuItem(passedOptions)
386 }
387
388 // :: (NodeType, Object) → MenuItem
389 // Build a menu item for changing the type of the textblock around the
390 // selection to the given type. Provides `run`, `active`, and `select`
391 // properties. Others must be given in `options`. `options.attrs` may
392 // be an object to provide the attributes for the textblock node.
393 export function blockTypeItem(nodeType, options) {
394   let command = setBlockType(nodeType, options.attrs)
395   let passedOptions = {
396     run: command,
397     enable(state) { return command(state) },
398     active(state) {
399       let {$from, to, node} = state.selection
400       if (node) return node.hasMarkup(nodeType, options.attrs)
401       return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
402     }
403   }
404   for (let prop in options) passedOptions[prop] = options[prop]
405   return new MenuItem(passedOptions)
406 }
407
408 export function setAttrItem(attrName, attrValue, options) {
409   const command = setBlockAttr(attrName, attrValue);
410   const passedOptions = {
411     run: command,
412     enable(state) { return command(state) },
413     active(state) {
414       const {$from, to, node} = state.selection
415       if (node) return node.attrs[attrValue] === attrValue;
416       return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
417     }
418   }
419   for (const prop in options) passedOptions[prop] = options[prop]
420   return new MenuItem(passedOptions)
421 }
422
423 export function insertBlockBeforeItem(blockType, options) {
424   const command = insertBlockBefore(blockType);
425   const passedOptions = {
426     run: command,
427     enable(state) { return command(state) },
428     active(state) {
429       return false;
430     }
431   }
432   for (const prop in options) passedOptions[prop] = options[prop]
433   return new MenuItem(passedOptions);
434 }
435
436 // Work around classList.toggle being broken in IE11
437 function setClass(dom, cls, on) {
438   if (on) dom.classList.add(cls)
439   else dom.classList.remove(cls)
440 }