]> BookStack Code Mirror - bookstack/blob - resources/js/editor/menu/menu.js
Started work on details/summary blocks
[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 import {renderDropdownItems, combineUpdates} from "./menu-utils";
13
14 import {getIcon, icons} from "./icons"
15 import {prefix} from "./menu-utils";
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
216 // ::- Represents a submenu wrapping a group of elements that start
217 // hidden and expand to the right when hovered over or tapped.
218 export class DropdownSubmenu {
219   // :: ([MenuElement], ?Object)
220   // Creates a submenu for the given group of menu elements. The
221   // following options are recognized:
222   //
223   // **`label`**`: string`
224   //   : The label to show on the submenu.
225   constructor(content, options) {
226     this.options = options || {}
227     this.content = Array.isArray(content) ? content : [content]
228   }
229
230   // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
231   // Renders the submenu.
232   render(view) {
233     const items = renderDropdownItems(this.content, view)
234
235     const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
236     const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
237                    crel("div", {class: prefix + "-submenu"}, items.dom))
238     let listeningOnClose = null
239     handleContent.addEventListener("mousedown", e => {
240       e.preventDefault()
241       markMenuEvent(e)
242       setClass(wrap, prefix + "-submenu-wrap-active")
243       if (!listeningOnClose)
244         window.addEventListener("mousedown", listeningOnClose = () => {
245           if (!isMenuEvent(wrap)) {
246             wrap.classList.remove(prefix + "-submenu-wrap-active")
247             window.removeEventListener("mousedown", listeningOnClose)
248             listeningOnClose = null
249           }
250         })
251     })
252
253     function update(state) {
254       let inner = items.update(state)
255       wrap.style.display = inner ? "" : "none"
256       return inner
257     }
258     return {dom: wrap, update}
259   }
260 }
261
262 // :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
263 // Render the given, possibly nested, array of menu elements into a
264 // document fragment, placing separators between them (and ensuring no
265 // superfluous separators appear when some of the groups turn out to
266 // be empty).
267 export function renderGrouped(view, content) {
268   let result = document.createDocumentFragment()
269   let updates = [], separators = []
270   for (let i = 0; i < content.length; i++) {
271     let items = content[i], localUpdates = [], localNodes = []
272     for (let j = 0; j < items.length; j++) {
273       let {dom, update} = items[j].render(view)
274       let span = crel("span", {class: prefix + "item"}, dom)
275       result.appendChild(span)
276       localNodes.push(span)
277       localUpdates.push(update)
278     }
279     if (localUpdates.length) {
280       updates.push(combineUpdates(localUpdates, localNodes))
281       if (i < content.length - 1)
282         separators.push(result.appendChild(separator()))
283     }
284   }
285
286   function update(state) {
287     let something = false, needSep = false
288     for (let i = 0; i < updates.length; i++) {
289       let hasContent = updates[i](state)
290       if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
291       needSep = hasContent
292       if (hasContent) something = true
293     }
294     return something
295   }
296   return {dom: result, update}
297 }
298
299 function separator() {
300   return crel("span", {class: prefix + "separator"})
301 }
302
303
304 // :: MenuItem
305 // Menu item for the `joinUp` command.
306 export const joinUpItem = new MenuItem({
307   title: "Join with above block",
308   run: joinUp,
309   select: state => joinUp(state),
310   icon: icons.join
311 })
312
313 // :: MenuItem
314 // Menu item for the `lift` command.
315 export const liftItem = new MenuItem({
316   title: "Lift out of enclosing block",
317   run: lift,
318   select: state => lift(state),
319   icon: icons.lift
320 })
321
322 // :: MenuItem
323 // Menu item for the `selectParentNode` command.
324 export const selectParentNodeItem = new MenuItem({
325   title: "Select parent node",
326   run: selectParentNode,
327   select: state => selectParentNode(state),
328   icon: icons.selectParentNode
329 })
330
331 // :: MenuItem
332 // Menu item for the `undo` command.
333 export let undoItem = new MenuItem({
334   title: "Undo last change",
335   run: undo,
336   enable: state => undo(state),
337   icon: icons.undo
338 })
339
340 // :: MenuItem
341 // Menu item for the `redo` command.
342 export let redoItem = new MenuItem({
343   title: "Redo last undone change",
344   run: redo,
345   enable: state => redo(state),
346   icon: icons.redo
347 })
348
349 // :: (NodeType, Object) → MenuItem
350 // Build a menu item for wrapping the selection in a given node type.
351 // Adds `run` and `select` properties to the ones present in
352 // `options`. `options.attrs` may be an object or a function.
353 export function wrapItem(nodeType, options) {
354   let passedOptions = {
355     run(state, dispatch) {
356       // FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
357       return wrapIn(nodeType, options.attrs)(state, dispatch)
358     },
359     select(state) {
360       return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
361     }
362   }
363   for (let prop in options) passedOptions[prop] = options[prop]
364   return new MenuItem(passedOptions)
365 }
366
367 // :: (NodeType, Object) → MenuItem
368 // Build a menu item for changing the type of the textblock around the
369 // selection to the given type. Provides `run`, `active`, and `select`
370 // properties. Others must be given in `options`. `options.attrs` may
371 // be an object to provide the attributes for the textblock node.
372 export function blockTypeItem(nodeType, options) {
373   let command = setBlockType(nodeType, options.attrs)
374   let passedOptions = {
375     run: command,
376     enable(state) { return command(state) },
377     active(state) {
378       let {$from, to, node} = state.selection
379       if (node) return node.hasMarkup(nodeType, options.attrs)
380       return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
381     }
382   }
383   for (let prop in options) passedOptions[prop] = options[prop]
384   return new MenuItem(passedOptions)
385 }
386
387 export function setAttrItem(attrName, attrValue, options) {
388   const command = setBlockAttr(attrName, attrValue);
389   const passedOptions = {
390     run: command,
391     enable(state) { return command(state) },
392     active(state) {
393       const {$from, to, node} = state.selection
394       if (node) return node.attrs[attrValue] === attrValue;
395       return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
396     }
397   }
398   for (const prop in options) passedOptions[prop] = options[prop]
399   return new MenuItem(passedOptions)
400 }
401
402 export function insertBlockBeforeItem(blockType, options) {
403   const command = insertBlockBefore(blockType);
404   const passedOptions = {
405     run: command,
406     enable(state) { return command(state) },
407     active(state) {
408       return false;
409     }
410   }
411   for (const prop in options) passedOptions[prop] = options[prop]
412   return new MenuItem(passedOptions);
413 }
414
415 // Work around classList.toggle being broken in IE11
416 function setClass(dom, cls, on) {
417   if (on) dom.classList.add(cls)
418   else dom.classList.remove(cls)
419 }