|
1 import React from 'react'; |
|
2 import ToolbarButtons from './ToolbarButtons'; |
|
3 import MarkButton from './MarkButton'; |
|
4 import CategoryButton from './CategoryButton'; |
|
5 import BlockButton from './BlockButton'; |
|
6 |
|
7 /** |
|
8 * Define the default node type. |
|
9 */ |
|
10 |
|
11 const DEFAULT_NODE = 'paragraph' |
|
12 |
|
13 |
|
14 /** |
|
15 * Render the toolbar. |
|
16 * |
|
17 * @return {Element} |
|
18 */ |
|
19 export default class Toolbar extends React.Component { |
|
20 |
|
21 /** |
|
22 * Deserialize the initial editor state. |
|
23 * |
|
24 * @type {Object} |
|
25 */ |
|
26 constructor(props) { |
|
27 super(props); |
|
28 this.editorRef = React.createRef(); |
|
29 } |
|
30 |
|
31 /** |
|
32 * Check if the current selection has a mark with `type` in it. |
|
33 * |
|
34 * @param {String} type |
|
35 * @return {Boolean} |
|
36 */ |
|
37 |
|
38 hasMark = type => { |
|
39 const { value } = this.props; |
|
40 return value.activeMarks.some(mark => mark.type === type); |
|
41 } |
|
42 |
|
43 /** |
|
44 * Check if the any of the currently selected blocks are of `type`. |
|
45 * |
|
46 * @param {String} type |
|
47 * @return {Boolean} |
|
48 */ |
|
49 |
|
50 hasBlock = type => { |
|
51 const { value } = this.props; |
|
52 return value.blocks.some(node => node.type === type) |
|
53 } |
|
54 |
|
55 |
|
56 /** |
|
57 * When a mark button is clicked, toggle the current mark. |
|
58 * |
|
59 * @param {Event} e |
|
60 * @param {String} type |
|
61 */ |
|
62 |
|
63 onClickMark = (e, type) => { |
|
64 this.props.editor.toggleMark(type) |
|
65 } |
|
66 |
|
67 isBlockActive = (type) => { |
|
68 let isActive = this.hasBlock(type) |
|
69 |
|
70 if (['numbered-list', 'bulleted-list'].includes(type)) { |
|
71 const { value } = this.props; |
|
72 const firstBlock = value.blocks.first(); |
|
73 if(firstBlock) { |
|
74 const parent = value.document.getParent(firstBlock.key); |
|
75 isActive = this.hasBlock('list-item') && parent && parent.type === type; |
|
76 } |
|
77 } |
|
78 |
|
79 return isActive; |
|
80 } |
|
81 |
|
82 onClickCategoryButton = (openPortal, closePortal, isOpen, e) => { |
|
83 e.preventDefault(); |
|
84 const { value, editor } = this.props; |
|
85 |
|
86 // Can't use toggleMark here, because it expects the same object |
|
87 // @see https://github.com/ianstormtaylor/slate/issues/873 |
|
88 if (this.hasMark('category')) { |
|
89 value.activeMarks.filter(mark => mark.type === 'category') |
|
90 .forEach(mark => editor.removeMark(mark)); |
|
91 closePortal(); |
|
92 } else { |
|
93 openPortal(); |
|
94 } |
|
95 } |
|
96 |
|
97 getSelectionParams = (value) => { |
|
98 |
|
99 const { selection } = value |
|
100 const { start, end} = selection |
|
101 |
|
102 if (selection.isCollapsed) { |
|
103 return {}; |
|
104 } |
|
105 |
|
106 const nodes = []; |
|
107 let hasStarted = false; |
|
108 let hasEnded = false; |
|
109 |
|
110 // Keep only the relevant nodes, |
|
111 // i.e. nodes which are contained within selection |
|
112 value.document.nodes.forEach((node) => { |
|
113 if (start.isInNode(node)) { |
|
114 hasStarted = true; |
|
115 } |
|
116 if (hasStarted && !hasEnded) { |
|
117 nodes.push(node); |
|
118 } |
|
119 if (end.isAtEndOfNode(node)) { |
|
120 hasEnded = true; |
|
121 } |
|
122 }); |
|
123 |
|
124 // Concatenate the nodes text |
|
125 const text = nodes.map((node) => { |
|
126 let textStart = start.isInNode(node) ? start.offset : 0; |
|
127 let textEnd = end.isInNode(node) ? end.offset : node.text.length; |
|
128 return node.text.substring(textStart,textEnd); |
|
129 }).join('\n'); |
|
130 |
|
131 return { |
|
132 currentSelectionText: text, |
|
133 currentSelectionStart: start.offset, |
|
134 currentSelectionEnd: end.offset |
|
135 }; |
|
136 } |
|
137 |
|
138 onCategoryClick = (closePortal, category) => { |
|
139 |
|
140 const { value, editor } = this.props; |
|
141 |
|
142 const { currentSelectionText, currentSelectionStart, currentSelectionEnd } = this.getSelectionParams(value); |
|
143 |
|
144 if(!currentSelectionText) { |
|
145 closePortal(); |
|
146 return; |
|
147 } |
|
148 |
|
149 const categoryMarks = value.activeMarks.filter(mark => mark.type === 'category') |
|
150 categoryMarks.forEach(mark => this.editor.removeMark(mark)); |
|
151 |
|
152 editor.addMark({ |
|
153 type: 'category', |
|
154 data: { |
|
155 text: currentSelectionText, |
|
156 selection: { |
|
157 start: currentSelectionStart, |
|
158 end: currentSelectionEnd, |
|
159 }, |
|
160 color: category.color, |
|
161 key: category.key, |
|
162 name: category.name, |
|
163 comment: category.comment |
|
164 } |
|
165 }) |
|
166 |
|
167 closePortal(); |
|
168 } |
|
169 |
|
170 /** |
|
171 * When a block button is clicked, toggle the block type. |
|
172 * |
|
173 * @param {Event} e |
|
174 * @param {String} type |
|
175 */ |
|
176 |
|
177 onClickBlock = (e, type) => { |
|
178 e.preventDefault(); |
|
179 |
|
180 const { editor, value } = this.props; |
|
181 |
|
182 // Handle everything but list buttons. |
|
183 if (type !== 'bulleted-list' && type !== 'numbered-list') { |
|
184 const isActive = this.hasBlock(type) |
|
185 const isList = this.hasBlock('list-item') |
|
186 |
|
187 if (isList) { |
|
188 editor |
|
189 .setBlocks(isActive ? DEFAULT_NODE : type) |
|
190 .unwrapBlock('bulleted-list') |
|
191 .unwrapBlock('numbered-list') |
|
192 } |
|
193 |
|
194 else { |
|
195 editor |
|
196 .setBlocks(isActive ? DEFAULT_NODE : type) |
|
197 } |
|
198 } |
|
199 |
|
200 // Handle the extra wrapping required for list buttons. |
|
201 else { |
|
202 const isList = this.hasBlock('list-item') |
|
203 const isType = value.blocks.some((block) => { |
|
204 return !!document.getClosest(block.key, parent => parent.type === type) |
|
205 }) |
|
206 |
|
207 if (isList && isType) { |
|
208 editor |
|
209 .setBlocks(DEFAULT_NODE) |
|
210 .unwrapBlock('bulleted-list') |
|
211 .unwrapBlock('numbered-list') |
|
212 |
|
213 } else if (isList) { |
|
214 editor |
|
215 .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list') |
|
216 .wrapBlock(type) |
|
217 |
|
218 } else { |
|
219 editor |
|
220 .setBlocks('list-item') |
|
221 .wrapBlock(type) |
|
222 |
|
223 } |
|
224 } |
|
225 } |
|
226 |
|
227 |
|
228 render = () => { |
|
229 return ( |
|
230 <div className="menu toolbar-menu d-flex sticky-top bg-secondary"> |
|
231 <MarkButton icon='format_bold' isActive={this.hasMark('bold')} onMouseDown={(e) => this.onClickMark(e, 'bold')} /> |
|
232 <MarkButton icon='format_italic' isActive={this.hasMark('italic')} onMouseDown={(e) => this.onClickMark(e, 'italic')} /> |
|
233 <MarkButton icon='format_underlined' isActive={this.hasMark('underlined')} onMouseDown={(e) => this.onClickMark(e, 'underlined')} /> |
|
234 |
|
235 <CategoryButton |
|
236 isActive={this.hasMark('category')} |
|
237 onClickCategoryButton={this.onClickCategoryButton} |
|
238 onCategoryClick={this.onCategoryClick} |
|
239 annotationCategories={this.props.annotationCategories} |
|
240 /> |
|
241 |
|
242 <BlockButton icon='format_list_numbered' isActive={this.isBlockActive('numbered-list')} onMouseDown={e => this.onClickBlock(e, 'numbered-list')} /> |
|
243 <BlockButton icon='format_list_bulleted' isActive={this.isBlockActive('bulleted-list')} onMouseDown={e => this.onClickBlock(e, 'bulleted-list')} /> |
|
244 |
|
245 <ToolbarButtons |
|
246 hasNote={!!this.props.note} |
|
247 isButtonDisabled={this.props.isButtonDisabled} |
|
248 submitNote={this.props.submitNote} |
|
249 /> |
|
250 |
|
251 </div> |
|
252 ) |
|
253 } |
|
254 } |