]> BookStack Code Mirror - bookstack/blob - resources/js/editor/menu/menubar.js
Started work on details/summary blocks
[bookstack] / resources / js / editor / menu / menubar.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 {Plugin} from "prosemirror-state"
10
11 import {renderGrouped} from "./menu"
12
13 const prefix = "ProseMirror-menubar"
14
15 function isIOS() {
16   if (typeof navigator == "undefined") return false
17   let agent = navigator.userAgent
18   return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
19 }
20
21 // :: (Object) → Plugin
22 // A plugin that will place a menu bar above the editor. Note that
23 // this involves wrapping the editor in an additional `<div>`.
24 //
25 //   options::-
26 //   Supports the following options:
27 //
28 //     content:: [[MenuElement]]
29 //     Provides the content of the menu, as a nested array to be
30 //     passed to `renderGrouped`.
31 //
32 //     floating:: ?bool
33 //     Determines whether the menu floats, i.e. whether it sticks to
34 //     the top of the viewport when the editor is partially scrolled
35 //     out of view.
36 export function menuBar(options) {
37   return new Plugin({
38     view(editorView) { return new MenuBarView(editorView, options) }
39   })
40 }
41
42 class MenuBarView {
43   constructor(editorView, options) {
44     this.editorView = editorView
45     this.options = options
46
47     this.wrapper = crel("div", {class: prefix + "-wrapper"})
48     this.menu = this.wrapper.appendChild(crel("div", {class: prefix}))
49     this.menu.className = prefix
50     this.spacer = null
51
52     if (editorView.dom.parentNode)
53       editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom)
54     this.wrapper.appendChild(editorView.dom)
55
56     this.maxHeight = 0
57     this.widthForMaxHeight = 0
58     this.floating = false
59
60     let {dom, update} = renderGrouped(this.editorView, this.options.content)
61     this.contentUpdate = update
62     this.menu.appendChild(dom)
63     this.update()
64
65     if (options.floating && !isIOS()) {
66       this.updateFloat()
67       let potentialScrollers = getAllWrapping(this.wrapper)
68       this.scrollFunc = (e) => {
69         let root = this.editorView.root
70         if (!(root.body || root).contains(this.wrapper)) {
71             potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc))
72         } else {
73             this.updateFloat(e.target.getBoundingClientRect && e.target)
74         }
75       }
76       potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc))
77     }
78   }
79
80   update() {
81     this.contentUpdate(this.editorView.state)
82
83     if (this.floating) {
84       this.updateScrollCursor()
85     } else {
86       if (this.menu.offsetWidth != this.widthForMaxHeight) {
87         this.widthForMaxHeight = this.menu.offsetWidth
88         this.maxHeight = 0
89       }
90       if (this.menu.offsetHeight > this.maxHeight) {
91         this.maxHeight = this.menu.offsetHeight
92         this.menu.style.minHeight = this.maxHeight + "px"
93       }
94     }
95   }
96
97   updateScrollCursor() {
98     let selection = this.editorView.root.getSelection()
99     if (!selection.focusNode) return
100     let rects = selection.getRangeAt(0).getClientRects()
101     let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1]
102     if (!selRect) return
103     let menuRect = this.menu.getBoundingClientRect()
104     if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) {
105       let scrollable = findWrappingScrollable(this.wrapper)
106       if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top)
107     }
108   }
109
110   updateFloat(scrollAncestor) {
111     let parent = this.wrapper, editorRect = parent.getBoundingClientRect(),
112         top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0
113
114     if (this.floating) {
115       if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) {
116         this.floating = false
117         this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = ""
118         this.menu.style.display = ""
119         this.spacer.parentNode.removeChild(this.spacer)
120         this.spacer = null
121       } else {
122         let border = (parent.offsetWidth - parent.clientWidth) / 2
123         this.menu.style.left = (editorRect.left + border) + "px"
124         this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "")
125         if (scrollAncestor) this.menu.style.top = top + "px"
126       }
127     } else {
128       if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) {
129         this.floating = true
130         let menuRect = this.menu.getBoundingClientRect()
131         this.menu.style.left = menuRect.left + "px"
132         this.menu.style.width = menuRect.width + "px"
133         if (scrollAncestor) this.menu.style.top = top + "px"
134         this.menu.style.position = "fixed"
135         this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`})
136         parent.insertBefore(this.spacer, this.menu)
137       }
138     }
139   }
140
141   destroy() {
142     if (this.wrapper.parentNode)
143       this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper)
144   }
145 }
146
147 // Not precise, but close enough
148 function selectionIsInverted(selection) {
149   if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset
150   return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING
151 }
152
153 function findWrappingScrollable(node) {
154   for (let cur = node.parentNode; cur; cur = cur.parentNode)
155     if (cur.scrollHeight > cur.clientHeight) return cur
156 }
157
158 function getAllWrapping(node) {
159     let res = [window]
160     for (let cur = node.parentNode; cur; cur = cur.parentNode)
161         res.push(cur)
162     return res
163 }