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";
12 import {renderDropdownItems, combineUpdates} from "./menu-utils";
14 import {getIcon, icons} from "./icons"
15 import {prefix} from "./menu-utils";
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}
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:
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]
230 // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
231 // Renders the submenu.
233 const items = renderDropdownItems(this.content, view)
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 => {
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
253 function update(state) {
254 let inner = items.update(state)
255 wrap.style.display = inner ? "" : "none"
258 return {dom: wrap, update}
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
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)
279 if (localUpdates.length) {
280 updates.push(combineUpdates(localUpdates, localNodes))
281 if (i < content.length - 1)
282 separators.push(result.appendChild(separator()))
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"
292 if (hasContent) something = true
296 return {dom: result, update}
299 function separator() {
300 return crel("span", {class: prefix + "separator"})
305 // Menu item for the `joinUp` command.
306 export const joinUpItem = new MenuItem({
307 title: "Join with above block",
309 select: state => joinUp(state),
314 // Menu item for the `lift` command.
315 export const liftItem = new MenuItem({
316 title: "Lift out of enclosing block",
318 select: state => lift(state),
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
332 // Menu item for the `undo` command.
333 export let undoItem = new MenuItem({
334 title: "Undo last change",
336 enable: state => undo(state),
341 // Menu item for the `redo` command.
342 export let redoItem = new MenuItem({
343 title: "Redo last undone change",
345 enable: state => redo(state),
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)
360 return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
363 for (let prop in options) passedOptions[prop] = options[prop]
364 return new MenuItem(passedOptions)
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 = {
376 enable(state) { return command(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)
383 for (let prop in options) passedOptions[prop] = options[prop]
384 return new MenuItem(passedOptions)
387 export function setAttrItem(attrName, attrValue, options) {
388 const command = setBlockAttr(attrName, attrValue);
389 const passedOptions = {
391 enable(state) { return command(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;
398 for (const prop in options) passedOptions[prop] = options[prop]
399 return new MenuItem(passedOptions)
402 export function insertBlockBeforeItem(blockType, options) {
403 const command = insertBlockBefore(blockType);
404 const passedOptions = {
406 enable(state) { return command(state) },
411 for (const prop in options) passedOptions[prop] = options[prop]
412 return new MenuItem(passedOptions);
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)