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
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";
13 import {getIcon, icons} from "./icons"
15 const prefix = "ProseMirror-menu"
17 // ::- An icon or label that, when clicked, executes a command.
18 export class MenuItem {
22 // The spec used to create the menu item.
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.
32 let dom = spec.render ? spec.render(view)
33 : spec.icon ? getIcon(spec.icon)
34 : spec.label ? crel("div", null, translate(view, spec.label))
36 if (!dom) throw new RangeError("MenuItem without icon or label property")
38 const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
39 dom.setAttribute("title", translate(view, title))
41 if (spec.class) dom.classList.add(spec.class)
42 if (spec.css) dom.style.cssText += spec.css
44 dom.addEventListener("mousedown", e => {
46 if (!dom.classList.contains(prefix + "-disabled"))
47 spec.run(view.state, view.dispatch, view, e)
50 function update(state) {
52 let selected = spec.select(state)
53 dom.style.display = selected ? "" : "none"
54 if (!selected) return false
58 enabled = spec.enable(state) || false
59 setClass(dom, prefix + "-disabled", !enabled)
62 let active = enabled && spec.active(state) || false
63 setClass(dom, prefix + "-active", active)
72 function translate(view, text) {
73 return view._props.translate ? view._props.translate(text) : text
76 // MenuItemSpec:: interface
77 // The configuration object passed to the `MenuItem` constructor.
79 // run:: (EditorState, (Transaction), EditorView, dom.Event)
80 // The function to execute when the menu item is activated.
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.
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
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).
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).
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
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.
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.
116 // title:: ?union<string, (EditorState) → string>
117 // Defines DOM title (mouseover) text for the item.
120 // Optionally adds a CSS class to the item's DOM representation.
123 // Optionally adds a string of inline CSS to the item's DOM
126 let lastMenuEvent = {time: 0, node: null}
127 function markMenuEvent(e) {
128 lastMenuEvent.time = Date.now()
129 lastMenuEvent.node = e.target
131 function isMenuEvent(wrapper) {
132 return Date.now() - 100 < lastMenuEvent.time &&
133 lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
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:
143 // **`label`**`: string`
144 // : The label to show on the drop-down control.
146 // **`title`**`: string`
148 // [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
149 // attribute given to the menu control.
151 // **`class`**`: string`
152 // : When given, adds an extra CSS class to the menu control.
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]
161 // :: (EditorView) → {dom: dom.Node, update: (EditorState)}
162 // Render the dropdown menu and sub-items.
164 let content = renderDropdownItems(this.content, view)
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
173 if (open && open.close()) {
175 window.removeEventListener("mousedown", listeningOnClose)
178 label.addEventListener("mousedown", e => {
184 open = this.expand(wrap, content.dom)
185 window.addEventListener("mousedown", listeningOnClose = () => {
186 if (!isMenuEvent(wrap)) close()
191 function update(state) {
192 let inner = content.update(state)
193 wrap.style.display = inner ? "" : "none"
197 return {dom: wrap, update}
201 let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
207 dom.removeChild(menuDOM)
210 dom.appendChild(menuDOM)
211 return {close, node: menuDOM}
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))
222 return {dom: rendered, update: combineUpdates(updates, rendered)}
225 function combineUpdates(updates, nodes) {
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
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:
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]
251 // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
252 // Renders the submenu.
254 const items = renderDropdownItems(this.content, view)
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 => {
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
274 function update(state) {
275 let inner = items.update(state)
276 wrap.style.display = inner ? "" : "none"
279 return {dom: wrap, update}
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
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)
300 if (localUpdates.length) {
301 updates.push(combineUpdates(localUpdates, localNodes))
302 if (i < content.length - 1)
303 separators.push(result.appendChild(separator()))
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"
313 if (hasContent) something = true
317 return {dom: result, update}
320 function separator() {
321 return crel("span", {class: prefix + "separator"})
326 // Menu item for the `joinUp` command.
327 export const joinUpItem = new MenuItem({
328 title: "Join with above block",
330 select: state => joinUp(state),
335 // Menu item for the `lift` command.
336 export const liftItem = new MenuItem({
337 title: "Lift out of enclosing block",
339 select: state => lift(state),
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
353 // Menu item for the `undo` command.
354 export let undoItem = new MenuItem({
355 title: "Undo last change",
357 enable: state => undo(state),
362 // Menu item for the `redo` command.
363 export let redoItem = new MenuItem({
364 title: "Redo last undone change",
366 enable: state => redo(state),
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)
381 return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
384 for (let prop in options) passedOptions[prop] = options[prop]
385 return new MenuItem(passedOptions)
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 = {
397 enable(state) { return command(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)
404 for (let prop in options) passedOptions[prop] = options[prop]
405 return new MenuItem(passedOptions)
408 export function setAttrItem(attrName, attrValue, options) {
409 const command = setBlockAttr(attrName, attrValue);
410 const passedOptions = {
412 enable(state) { return command(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;
419 for (const prop in options) passedOptions[prop] = options[prop]
420 return new MenuItem(passedOptions)
423 export function insertBlockBeforeItem(blockType, options) {
424 const command = insertBlockBefore(blockType);
425 const passedOptions = {
427 enable(state) { return command(state) },
432 for (const prop in options) passedOptions[prop] = options[prop]
433 return new MenuItem(passedOptions);
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)