56 |
${utils.localize('elements-label')}:
57 |
58 |
59 |
65 |
${params.colorTitle}:
66 |
67 |
68 |
69 |
70 |
${utils.localize('elements-background-color')}:
71 |
72 |
73 |
74 |
75 |
`
76 | }
77 |
78 | _changeElementInfo (name, value) {
79 | if (name === 'name') {
80 | this.selected[0].data({ name: value })
81 | return
82 | }
83 | this.selected.forEach(item => {
84 | if (item.isEdge() && name === 'color') {
85 | name = 'lineColor'
86 | }
87 | if (name === 'background-color') {
88 | name = 'bg'
89 | }
90 | item.data({
91 | [name]: value
92 | })
93 | })
94 | }
95 |
96 | showElementsInfo () {
97 | let selected = this.cy.$(':selected')
98 | this.selected = selected
99 | let allNode = selected.every(it => it.isNode())
100 | let opt = { showName: allNode, showBgColor: allNode, showColor: true, showRect: allNode, colorTitle: allNode ? utils.localize('elements-text-color') : utils.localize('elements-color') }
101 | if (selected.length > 1) {
102 | this._infos.name = ''
103 | this._panelHtml(opt)
104 | } else if (selected.length === 1) {
105 | this._panelHtml(opt)
106 | this._panel.style.display = 'block'
107 | let el = selected[0]
108 | this._options.attrs.forEach(item => {
109 | if (item === 'name') { // from data
110 | this._infos[item] = el.data('name')
111 | } else if (item === 'color' || item === 'background-color') {
112 | let color = el.numericStyle(item)
113 | this._infos[item] = '#' + utils.RGBToHex(...color)
114 | } else {
115 | this._infos[item] = el.numericStyle(item)
116 | }
117 | })
118 | } else {
119 | this._panel.style.display = 'none'
120 | }
121 | this._options.attrs.filter(item => this._infos[item]).forEach(name => {
122 | let item = utils.query(`#info-items input[name=${name}`)
123 | if (item.length) {
124 | item[0].value = this._infos[name]
125 | }
126 | })
127 | }
128 | }
129 |
130 | export default (cytoscape) => {
131 | if (!cytoscape) { return }
132 |
133 | cytoscape('core', 'editElements', function (params) {
134 | return new EditElements(this, params)
135 | })
136 | }
137 |
--------------------------------------------------------------------------------
/src/lib/cyeditor-navigator/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by DemonRay on 2019/3/20.
3 | */
4 | import utils from '../../utils'
5 |
6 | let defaults = {
7 | container: false, // can be a selector
8 | viewLiveFramerate: 0, // set false to update graph pan only on drag end; set 0 to do it instantly; set a number (frames per second) to update not more than N times per second
9 | dblClickDelay: 200, // milliseconds
10 | removeCustomContainer: true, // destroy the container specified by user on plugin destroy
11 | rerenderDelay: 500 // ms to throttle rerender updates to the panzoom for performance
12 | }
13 |
14 | class Navigator {
15 | constructor (cy, options) {
16 | this.cy = cy
17 | this._options = utils.extend({}, defaults, options)
18 | this._init(cy, options)
19 | }
20 |
21 | _init () {
22 | this._cyListeners = []
23 | this._contianer = this.cy.container()
24 |
25 | // Cache bounding box
26 | this.boundingBox = this.bb()
27 |
28 | let eleRect = this._contianer.getBoundingClientRect()
29 | // Cache sizes
30 | this.width = eleRect.width
31 | this.height = eleRect.height
32 |
33 | // Init components
34 | this._initPanel()
35 | this._initThumbnail()
36 | this._initView()
37 | this._initOverlay()
38 | }
39 |
40 | _addCyListener (events, handler) {
41 | this._cyListeners.push({
42 | events: events,
43 | handler: handler
44 | })
45 |
46 | this.cy.on(events, handler)
47 | }
48 |
49 | _removeCyListeners () {
50 | let cy = this.cy
51 |
52 | this._cyListeners.forEach(function (l) {
53 | cy.off(l.events, l.handler)
54 | })
55 |
56 | cy.offRender(this._onRenderHandler)
57 | }
58 |
59 | _initPanel () {
60 | let options = this._options
61 |
62 | if (options.container) {
63 | if (typeof options.container === 'string') {
64 | this.$panel = utils.query(options.container)[ 0 ]
65 | } else if (utils.isNode(options.container)) {
66 | this.$panel = options.container
67 | }
68 | if (!this.$panel) {
69 | console.error('There is no any element matching your container')
70 | return
71 | }
72 | } else {
73 | this.$panel = document.createElement('div')
74 | document.body.appendChild(this.$panel)
75 | }
76 |
77 | this.$panel.classList.add('cytoscape-navigator')
78 |
79 | this._setupPanel()
80 | this._addCyListener('resize', this.resize.bind(this))
81 | }
82 |
83 | _setupPanel () {
84 | let panelRect = this.$panel.getBoundingClientRect()
85 | // Cache sizes
86 | this.panelWidth = panelRect.width
87 | this.panelHeight = panelRect.height
88 | }
89 |
90 | _initThumbnail () {
91 | // Create thumbnail
92 | this.$thumbnail = document.createElement('img')
93 |
94 | // Add thumbnail canvas to the DOM
95 | this.$panel.appendChild(this.$thumbnail)
96 |
97 | // Setup thumbnail
98 | this._setupThumbnailSizes()
99 | this._updateThumbnailImage()
100 | }
101 |
102 | _setupThumbnailSizes () {
103 | // Update bounding box cache
104 | this.boundingBox = this.bb()
105 |
106 | this.thumbnailZoom = Math.min(this.panelHeight / this.boundingBox.h, this.panelWidth / this.boundingBox.w)
107 |
108 | // Used on thumbnail generation
109 | this.thumbnailPan = {
110 | x: (this.panelWidth - this.thumbnailZoom * (this.boundingBox.x1 + this.boundingBox.x2)) / 2,
111 | y: (this.panelHeight - this.thumbnailZoom * (this.boundingBox.y1 + this.boundingBox.y2)) / 2
112 | }
113 | }
114 |
115 | // If bounding box has changed then update sizes
116 | // Otherwise just update the thumbnail
117 | _checkThumbnailSizesAndUpdate () {
118 | // Cache previous values
119 | let _zoom = this.thumbnailZoom
120 | let _panX = this.thumbnailPan.x
121 | let _panY = this.thumbnailPan.y
122 |
123 | this._setupThumbnailSizes()
124 |
125 | this._updateThumbnailImage()
126 | if (_zoom !== this.thumbnailZoom || _panX !== this.thumbnailPan.x || _panY !== this.thumbnailPan.y) {
127 | this._setupView()
128 | }
129 | }
130 |
131 | _initView () {
132 | this.$view = document.createElement('div')
133 | this.$view.className = 'cytoscape-navigatorView'
134 | this.$panel.appendChild(this.$view)
135 |
136 | let viewStyle = window.getComputedStyle(this.$view)
137 |
138 | // Compute borders
139 | this.viewBorderTop = parseInt(viewStyle[ 'border-top-width' ], 10)
140 | this.viewBorderRight = parseInt(viewStyle[ 'border-right-width' ], 10)
141 | this.viewBorderBottom = parseInt(viewStyle[ 'border-bottom-width' ], 10)
142 | this.viewBorderLeft = parseInt(viewStyle[ 'border-left-width' ], 10)
143 |
144 | // Abstract borders
145 | this.viewBorderHorizontal = this.viewBorderLeft + this.viewBorderRight
146 | this.viewBorderVertical = this.viewBorderTop + this.viewBorderBottom
147 |
148 | this._setupView()
149 |
150 | // Hook graph zoom and pan
151 | this._addCyListener('zoom pan', this._setupView.bind(this))
152 | }
153 |
154 | _setupView () {
155 | if (this.viewLocked) { return }
156 |
157 | let cyZoom = this.cy.zoom()
158 | let cyPan = this.cy.pan()
159 |
160 | // Horizontal computation
161 | this.viewW = this.width / cyZoom * this.thumbnailZoom
162 | this.viewX = -cyPan.x * this.viewW / this.width + this.thumbnailPan.x - this.viewBorderLeft
163 |
164 | // Vertical computation
165 | this.viewH = this.height / cyZoom * this.thumbnailZoom
166 | this.viewY = -cyPan.y * this.viewH / this.height + this.thumbnailPan.y - this.viewBorderTop
167 |
168 | // CSS view
169 | this.$view.style.width = this.viewW + 'px'
170 | this.$view.style.height = this.viewH + 'px'
171 | this.$view.style.position = 'absolute'
172 | this.$view.style.left = this.viewX + 'px'
173 | this.$view.style.top = this.viewY + 'px'
174 | }
175 |
176 | /*
177 | * Used inner attributes
178 | *
179 | * timeout {number} used to keep stable frame rate
180 | * lastMoveStartTime {number}
181 | * inMovement {boolean}
182 | * hookPoint {object} {x: 0, y: 0}
183 | */
184 | _initOverlay () {
185 | // Used to capture mouse events
186 | this.$overlay = document.createElement('div')
187 | this.$overlay.className = 'cytoscape-navigatorOverlay'
188 |
189 | // Add overlay to the DOM
190 | this.$panel.appendChild(this.$overlay)
191 |
192 | // Init some attributes
193 | this.overlayHookPointX = 0
194 | this.overlayHookPointY = 0
195 |
196 | // Listen for events
197 | this._initEventsHandling()
198 | }
199 |
200 | _initEventsHandling () {
201 | let eventsLocal = [
202 | // Mouse events
203 | 'mousedown',
204 | 'mousewheel',
205 | 'DOMMouseScroll', // Mozilla specific event
206 | // Touch events
207 | 'touchstart'
208 | ]
209 | let eventsGlobal = [
210 | 'mouseup',
211 | 'mouseout',
212 | 'mousemove',
213 | // Touch events
214 | 'touchmove',
215 | 'touchend'
216 | ]
217 |
218 | // handle events and stop their propagation
219 | let overlayListener = (env) => {
220 | // Touch events
221 | let ev = utils.extend({}, env)
222 | if (ev.type === 'touchstart') {
223 | // Will count as middle of View
224 | ev.offsetX = this.viewX + this.viewW / 2
225 | ev.offsetY = this.viewY + this.viewH / 2
226 | }
227 |
228 | // Normalize offset for browsers which do not provide that value
229 | if (ev.offsetX === undefined || ev.offsetY === undefined) {
230 | let targetOffset = utils.offset(ev.target)
231 | ev.offsetX = ev.pageX - targetOffset.left
232 | ev.offsetY = ev.pageY - targetOffset.top
233 | }
234 |
235 | if (ev.type === 'mousedown' || ev.type === 'touchstart') {
236 | this._eventMoveStart(ev)
237 | } else if (ev.type === 'mousewheel' || ev.type === 'DOMMouseScroll') {
238 | this._eventZoom(ev)
239 | }
240 |
241 | env.preventDefault()
242 | // Prevent default and propagation
243 | // Don't use peventPropagation as it breaks mouse events
244 | return false
245 | }
246 |
247 | // Hook global events
248 | let globalListener = (env) => {
249 | let ev = utils.extend({}, env)
250 | // Do not make any computations if it is has no effect on Navigator
251 | if (!this.overlayInMovement) return
252 | // Touch events
253 | if (ev.type === 'touchend') {
254 | // Will count as middle of View
255 | ev.offsetX = this.viewX + this.viewW / 2
256 | ev.offsetY = this.viewY + this.viewH / 2
257 | } else if (ev.type === 'touchmove') {
258 | // Hack - we take in account only first touch
259 | ev.pageX = ev.touches[ 0 ].pageX
260 | ev.pageY = ev.touches[ 0 ].pageY
261 | }
262 |
263 | // Normalize offset for browsers which do not provide that value
264 | if (ev.target && (ev.offsetX === undefined || ev.offsetY === undefined)) {
265 | let targetOffset = utils.offset(ev.target)
266 | ev.offsetX = ev.pageX - targetOffset.left
267 | ev.offsetY = ev.pageY - targetOffset.top
268 | }
269 |
270 | // Translate global events into local coordinates
271 | if (ev.target && ev.target !== this.$overlay) {
272 | let targetOffset = utils.offset(ev.target)
273 | let overlayOffset = utils.offset(this.$overlay)
274 |
275 | if (targetOffset && overlayOffset) {
276 | ev.offsetX = ev.offsetX - overlayOffset.left + targetOffset.left
277 | ev.offsetY = ev.offsetY - overlayOffset.top + targetOffset.top
278 | } else {
279 | return false
280 | }
281 | }
282 |
283 | if (ev.type === 'mousemove' || ev.type === 'touchmove') {
284 | this._eventMove(ev)
285 | } else if (ev.type === 'mouseup' || ev.type === 'touchend') {
286 | this._eventMoveEnd(ev)
287 | }
288 |
289 | env.preventDefault()
290 | // Prevent default and propagation
291 | // Don't use peventPropagation as it breaks mouse events
292 | return false
293 | }
294 |
295 | eventsLocal.forEach((item) => {
296 | this.$overlay.addEventListener(item, overlayListener)
297 | })
298 |
299 | eventsGlobal.forEach((item) => {
300 | window.addEventListener(item, globalListener)
301 | })
302 |
303 | this._removeEventsHandling = () => {
304 | eventsGlobal.forEach(item => {
305 | window.removeEventListener(item, globalListener)
306 | })
307 | eventsLocal.forEach(item => {
308 | this.$overlay.addEventListener(item, overlayListener)
309 | })
310 | }
311 | }
312 |
313 | _eventMoveStart (ev) {
314 | let now = new Date().getTime()
315 |
316 | // Check if it was double click
317 | if (this.overlayLastMoveStartTime &&
318 | this.overlayLastMoveStartTime + this._options.dblClickDelay > now) {
319 | // Reset lastMoveStartTime
320 | this.overlayLastMoveStartTime = 0
321 | // Enable View in order to move it to the center
322 | this.overlayInMovement = true
323 |
324 | // Set hook point as View center
325 | this.overlayHookPointX = this.viewW / 2
326 | this.overlayHookPointY = this.viewH / 2
327 |
328 | // Move View to start point
329 | if (this._options.viewLiveFramerate !== false) {
330 | this._eventMove({
331 | offsetX: this.panelWidth / 2,
332 | offsetY: this.panelHeight / 2
333 | })
334 | } else {
335 | this._eventMoveEnd({
336 | offsetX: this.panelWidth / 2,
337 | offsetY: this.panelHeight / 2
338 | })
339 | }
340 |
341 | this.cy.reset()
342 |
343 | // View should be inactive as we don't want to move it right after double click
344 | this.overlayInMovement = false
345 | } else {
346 | // This is a single click
347 | // Take care as single click happens before double click 2 times
348 | this.overlayLastMoveStartTime = now
349 | this.overlayInMovement = true
350 | // Lock view moving caused by cy events
351 | this.viewLocked = true
352 |
353 | // if event started in View
354 | if (ev.offsetX >= this.viewX && ev.offsetX <= this.viewX + this.viewW &&
355 | ev.offsetY >= this.viewY && ev.offsetY <= this.viewY + this.viewH
356 | ) {
357 | this.overlayHookPointX = ev.offsetX - this.viewX
358 | this.overlayHookPointY = ev.offsetY - this.viewY
359 | } else {
360 | // Set hook point as View center
361 | this.overlayHookPointX = this.viewW / 2
362 | this.overlayHookPointY = this.viewH / 2
363 |
364 | // Move View to start point
365 | this._eventMove(ev)
366 | }
367 | }
368 | }
369 |
370 | _eventMove (ev) {
371 | this._checkMousePosition(ev)
372 |
373 | // break if it is useless event
374 | if (!this.overlayInMovement) {
375 | return
376 | }
377 |
378 | // Update cache
379 | this.viewX = ev.offsetX - this.overlayHookPointX
380 | this.viewY = ev.offsetY - this.overlayHookPointY
381 |
382 | // Update view position
383 | this.$view.style.left = this.viewX + 'px'
384 | this.$view.style.top = this.viewY + 'px'
385 |
386 | // Move Cy
387 | if (this._options.viewLiveFramerate !== false) {
388 | // trigger instantly
389 | if (this._options.viewLiveFramerate === 0) {
390 | this._moveCy()
391 | } else if (!this.overlayTimeout) {
392 | // Set a timeout for graph movement
393 | this.overlayTimeout = setTimeout(() => {
394 | this._moveCy()
395 | this.overlayTimeout = false
396 | }, 1000 / this._options.viewLiveFramerate)
397 | }
398 | }
399 | }
400 |
401 | _checkMousePosition (ev) {
402 | // If mouse in over View
403 | if (ev.offsetX > this.viewX && ev.offsetX < this.viewX + this.viewBorderHorizontal + this.viewW &&
404 | ev.offsetY > this.viewY && ev.offsetY < this.viewY + this.viewBorderVertical + this.viewH) {
405 | this.$panel.classList.add('mouseover-view')
406 | } else {
407 | this.$panel.classList.remove('mouseover-view')
408 | }
409 | }
410 |
411 | _eventMoveEnd (ev) {
412 | // Unlock view changing caused by graph events
413 | this.viewLocked = false
414 |
415 | // Remove class when mouse is not over Navigator
416 | this.$panel.classList.remove('mouseover-view')
417 |
418 | if (!this.overlayInMovement) {
419 | return
420 | }
421 |
422 | // Trigger one last move
423 | this._eventMove(ev)
424 |
425 | // If mode is not live then move graph on drag end
426 | if (this._options.viewLiveFramerate === false) {
427 | this._moveCy()
428 | }
429 |
430 | // Stop movement permission
431 | this.overlayInMovement = false
432 | }
433 |
434 | _eventZoom (ev) {
435 | let zoomRate = Math.pow(10, ev.wheelDeltaY / 1000 || ev.wheelDelta / 1000 || ev.detail / -32)
436 | let mousePosition = {
437 | left: ev.offsetX,
438 | top: ev.offsetY
439 | }
440 | if (this.cy.zoomingEnabled()) {
441 | this._zoomCy(zoomRate, mousePosition)
442 | }
443 | }
444 |
445 | _updateThumbnailImage () {
446 | if (this._thumbnailUpdating) {
447 | return
448 | }
449 |
450 | this._thumbnailUpdating = true
451 |
452 | let render = () => {
453 | this._checkThumbnailSizesAndUpdate()
454 | this._setupView()
455 |
456 | let img = this.$thumbnail
457 | if (!img) return
458 |
459 | let w = this.panelWidth
460 | let h = this.panelHeight
461 | let bb = this.boundingBox
462 | let zoom = Math.min(w / bb.w, h / bb.h)
463 |
464 | let translate = {
465 | x: (w - zoom * (bb.w)) / 2,
466 | y: (h - zoom * (bb.h)) / 2
467 | }
468 |
469 | let png = this.cy.png({
470 | full: true,
471 | scale: zoom
472 | })
473 |
474 | if (png.indexOf('image/png') < 0) {
475 | img.removeAttribute('src')
476 | } else {
477 | img.setAttribute('src', png)
478 | }
479 |
480 | img.style.position = 'absolute'
481 | img.style.left = translate.x + 'px'
482 | img.style.top = translate.y + 'px'
483 | }
484 |
485 | this._onRenderHandler = utils.throttle(render, this._options.rerenderDelay)
486 |
487 | this.cy.onRender(this._onRenderHandler)
488 | }
489 |
490 | _moveCy () {
491 | this.cy.pan({
492 | x: -(this.viewX + this.viewBorderLeft - this.thumbnailPan.x) * this.width / this.viewW,
493 | y: -(this.viewY + this.viewBorderLeft - this.thumbnailPan.y) * this.height / this.viewH
494 | })
495 | }
496 |
497 | _zoomCy (zoomRate) {
498 | let zoomCenter = {
499 | x: this.width / 2,
500 | y: this.height / 2
501 | }
502 |
503 | this.cy.zoom({
504 | level: this.cy.zoom() * zoomRate,
505 | renderedPosition: zoomCenter
506 | })
507 | }
508 |
509 | destroy () {
510 | this._removeCyListeners()
511 | this._removeEventsHandling()
512 |
513 | // If container is not created by navigator and its removal is prohibited
514 | if (this._options.container && !this._options.removeCustomContainer) {
515 | let childs = this.$panel.childNodes
516 | for (let i = childs.length - 1; i >= 0; i--) {
517 | this.$panel.removeChild(childs[ i ])
518 | }
519 | } else {
520 | this.$panel.parentNode.removeChild(this.$panel)
521 | }
522 | }
523 |
524 | resize () {
525 | // Cache sizes
526 | let panelRect = this._contianer.getBoundingClientRect()
527 | this.width = panelRect.width
528 | this.height = panelRect.height
529 | this._setupPanel()
530 | this._checkThumbnailSizesAndUpdate()
531 | this._setupView()
532 | }
533 |
534 | bb () {
535 | let bb = this.cy.elements().boundingBox()
536 |
537 | if (bb.w === 0 || bb.h === 0) {
538 | return {
539 | x1: 0,
540 | x2: Infinity,
541 | y1: 0,
542 | y2: Infinity,
543 | w: Infinity,
544 | h: Infinity
545 | }
546 | }
547 |
548 | return bb
549 | }
550 | }
551 |
552 | export default (cytoscape) => {
553 | if (!cytoscape) { return }
554 |
555 | cytoscape('core', 'navigator', function (options) {
556 | return new Navigator(this, options)
557 | })
558 | }
559 |
--------------------------------------------------------------------------------
/src/lib/cyeditor-node-resize/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by DemonRay on 2019/3/22.
3 | */
4 |
5 | import utils from '../../utils'
6 |
7 | let defaults = {
8 | handleColor: '#000000', // the colour of the handle and the line drawn from it
9 | enabled: true, // whether to start the plugin in the enabled state
10 | minNodeWidth: 30,
11 | minNodeHeight: 30,
12 | triangleSize: 10,
13 | selector: 'node',
14 | lines: 3,
15 | padding: 5,
16 |
17 | start: function (sourceNode) {
18 | // fired when noderesize interaction starts (drag on handle)
19 | },
20 | complete: function (sourceNode, targetNodes, addedEntities) {
21 | // fired when noderesize is done and entities are added
22 | },
23 | stop: function (sourceNode) {
24 | // fired when noderesize interaction is stopped (either complete with added edges or incomplete)
25 | }
26 | }
27 |
28 | /**
29 | * Checks if the point p is inside the triangle p0,p1,p2
30 | * using barycentric coordinates
31 | */
32 | function ptInTriangle (p, p0, p1, p2) {
33 | let A = 1 / 2 * (-p1.y * p2.x + p0.y * (-p1.x + p2.x) + p0.x * (p1.y - p2.y) + p1.x * p2.y)
34 | let sign = A < 0 ? -1 : 1
35 | let s = (p0.y * p2.x - p0.x * p2.y + (p2.y - p0.y) * p.x + (p0.x - p2.x) * p.y) * sign
36 | let t = (p0.x * p1.y - p0.y * p1.x + (p0.y - p1.y) * p.x + (p1.x - p0.x) * p.y) * sign
37 |
38 | return s > 0 && t > 0 && (s + t) < 2 * A * sign
39 | }
40 |
41 | class NodeResize {
42 | constructor (cy, params) {
43 | this.cy = cy
44 | this._container = this.cy.container()
45 | this._listeners = {}
46 | this._drawMode = false
47 | this._drawsClear = true
48 | this._options = {}
49 | this._init(params)
50 | }
51 |
52 | destroy () {
53 | let data = this._options
54 |
55 | if (!data) {
56 | return
57 | }
58 | data.unbind()
59 | this._options = {}
60 | }
61 |
62 | disable () {
63 | this._options.enabled = false
64 | this._options.disabled = true
65 | }
66 |
67 | enable () {
68 | this._options.enabled = true
69 | this._options.disabled = false
70 | }
71 |
72 | resize () {
73 | this.cy.trigger('cyeditor.noderesize-resize')
74 | }
75 |
76 | drawon () {
77 | this._drawMode = true
78 | this._prevUngrabifyState = this.cy.autoungrabify()
79 | this.cy.autoungrabify(true)
80 | this.cy.trigger('cyeditor.noderesize-drawon')
81 | }
82 |
83 | drawoff () {
84 | this._drawMode = false
85 | this.cy.autoungrabify(this._prevUngrabifyState)
86 | this.cy.trigger('cyeditor.noderesize-drawoff')
87 | }
88 |
89 | _init (params) {
90 | this._options = utils.extend(true, {}, defaults, params)
91 | this.canvas = document.createElement('canvas')
92 | this.ctx = this.canvas.getContext('2d')
93 | this._container.append(this.canvas)
94 | this._listeners._sizeCanvas = utils.debounce(this._sizeCanvas, 250).bind(this)
95 | this._listeners._sizeCanvas()
96 |
97 | this._initEvents()
98 | }
99 |
100 | _sizeCanvas () {
101 | let rect = this._container.getBoundingClientRect()
102 | this.canvas.width = rect.width
103 | this.canvas.height = rect.height
104 | utils.css(this.canvas, {
105 | 'position': 'absolute',
106 | 'top': 0,
107 | 'left': 0,
108 | 'zIndex': '999'
109 | })
110 |
111 | setTimeout(() => {
112 | let canvasBb = utils.offset(this.canvas)
113 | let containerBb = utils.offset(this._container)
114 | utils.css(this.canvas, {
115 | 'top': -(canvasBb.top - containerBb.top) + 'px',
116 | 'left': -(canvasBb.left - containerBb.left) + 'px'
117 | })
118 | }, 0)
119 | }
120 |
121 | clearDraws () {
122 | if (this._drawsClear) {
123 | return
124 | }
125 |
126 | let containerRect = this._container.getBoundingClientRect()
127 |
128 | let w = containerRect.width
129 | let h = containerRect.height
130 |
131 | this.canvas.getContext('2d').clearRect(0, 0, w, h)
132 | this._drawsClear = true
133 | }
134 |
135 | _disableGestures () {
136 | this._lastPanningEnabled = this.cy.panningEnabled()
137 | this._lastZoomingEnabled = this.cy.zoomingEnabled()
138 | this._lastBoxSelectionEnabled = this.cy.boxSelectionEnabled()
139 |
140 | this.cy
141 | .zoomingEnabled(false)
142 | .panningEnabled(false)
143 | .boxSelectionEnabled(false)
144 | }
145 |
146 | _resetGestures () {
147 | this.cy
148 | .zoomingEnabled(this._lastZoomingEnabled)
149 | .panningEnabled(this._lastPanningEnabled)
150 | .boxSelectionEnabled(this._lastBoxSelectionEnabled)
151 | }
152 |
153 | _resetToDefaultState () {
154 | this.clearDraws()
155 | this.sourceNode = null
156 | this._resetGestures()
157 | }
158 |
159 | _drawHandle (node) {
160 | this.ctx.fillStyle = this._options.handleColor
161 | this.ctx.strokeStyle = this._options.handleColor
162 | let padding = this._options.padding * this.cy.zoom()
163 | let p = node.renderedPosition()
164 | let w = node.renderedOuterWidth() + padding * 2
165 | let h = node.renderedOuterHeight() + padding * 2
166 | let ts = this._options.triangleSize * this.cy.zoom()
167 |
168 | let x1 = p.x + w / 2 - ts
169 | let y1 = p.y + h / 2
170 | let x2 = p.x + w / 2
171 | let y2 = p.y + h / 2 - ts
172 |
173 | let lines = this._options.lines
174 | let wStep = ts / lines
175 | let hStep = ts / lines
176 | let lw = 1.5 * this.cy.zoom()
177 | for (let i = 0; i < lines - 1; i++) {
178 | this.ctx.beginPath()
179 | this.ctx.moveTo(x1, y1)
180 | this.ctx.lineTo(x2, y2)
181 | this.ctx.lineTo(x2, y2 + lw)
182 | this.ctx.lineTo(x1 + lw, y1)
183 | this.ctx.lineTo(x1, y1)
184 | this.ctx.closePath()
185 | this.ctx.fill()
186 | x1 += wStep
187 | y2 += hStep
188 | }
189 | this.ctx.beginPath()
190 | this.ctx.moveTo(x2, y1)
191 | this.ctx.lineTo(x2, y2)
192 | this.ctx.lineTo(x1, y1)
193 | this.ctx.closePath()
194 | this.ctx.fill()
195 |
196 | this._drawsClear = false
197 | }
198 |
199 | _initEvents () {
200 | window.addEventListener('resize', this._listeners._sizeCanvas)
201 | this.cy.on('cyeditor.noderesize-resize', this._listeners._sizeCanvas)
202 | this._grabbingNode = false
203 |
204 | let hoverTimeout
205 | this._lastPanningEnabled = this.cy.panningEnabled()
206 | this._lastZoomingEnabled = this.cy.zoomingEnabled()
207 | this._lastBoxSelectionEnabled = this.cy.boxSelectionEnabled()
208 |
209 | this.cy.style().selector('.noderesize-resized').css({
210 | 'width': 'data(width)',
211 | 'height': 'data(height)'
212 | })
213 |
214 | this._listeners.transformHandler = () => {
215 | this.clearDraws()
216 | }
217 | this._listeners._startHandler = this._startHandler.bind(this)
218 |
219 | this.cy.bind('zoom pan', this._listeners.transformHandler)
220 |
221 | let hoverHandler = () => {
222 | if (this._options.disabledd || this._drawMode) {
223 | return // ignore preview nodes
224 | }
225 |
226 | if (this._mdownOnHandle) { // only handle mdown case
227 | return false
228 | }
229 | }
230 | let leaveHandler = () => {
231 | if (this._drawMode) {
232 | return
233 | }
234 |
235 | if (this._mdownOnHandle) {
236 | clearTimeout(hoverTimeout)
237 | }
238 | }
239 | let freeNodeHandler = () => {
240 | this._grabbingNode = false
241 | }
242 | let dragNodeHandler = () => {
243 | if (this._drawMode) {
244 | return
245 | }
246 | setTimeout(() => this.clearDraws(), 50)
247 | }
248 | let removeHandler = (e) => {
249 | let id = e.target.id()
250 |
251 | if (id === this._lastActiveId) {
252 | setTimeout(() => {
253 | this._resetToDefaultState()
254 | }, 16)
255 | }
256 | }
257 | let tapToStartHandler = (e) => {
258 | let node = e.target
259 |
260 | if (!this.sourceNode) { // must not be active
261 | setTimeout(() => {
262 | this.clearDraws() // clear just in case
263 |
264 | this._drawHandle(node)
265 |
266 | node.trigger('cyeditor.noderesize-showhandle')
267 | }, 16)
268 | }
269 | }
270 | let dragHandler = () => {
271 | this._grabbingNode = true
272 | }
273 | let grabHandler = () => {
274 | this.clearDraws()
275 | }
276 | let selector = this._options.selector
277 | this.cy.on('mouseover tap', selector, this._listeners._startHandler)
278 | .on('mouseover tapdragover', selector, hoverHandler)
279 | .on('mouseout tapdragout', selector, leaveHandler)
280 | .on('drag position', selector, dragNodeHandler)
281 | .on('grab', selector, grabHandler)
282 | .on('drag', selector, dragHandler)
283 | .on('free', selector, freeNodeHandler)
284 | .on('remove', selector, removeHandler)
285 | .on('tap', selector, tapToStartHandler)
286 |
287 | this._options.unbind = () => {
288 | window.removeEventListener('resize', this._listeners._sizeCanvas)
289 | this.cy.off('mouseover', selector, this._listeners._startHandler)
290 | .off('mouseover', selector, hoverHandler)
291 | .off('mouseout', selector, leaveHandler)
292 | .off('drag position', selector, dragNodeHandler)
293 | .off('grab', selector, grabHandler)
294 | .off('free', selector, freeNodeHandler)
295 | .off('remove', selector, removeHandler)
296 | .off('tap', selector, tapToStartHandler)
297 |
298 | this.cy.unbind('zoom pan', this._listeners.transformHandler)
299 | }
300 | }
301 |
302 | _startHandler (e) {
303 | let node = e.target
304 |
305 | if (this._options.disabledd || this._drawMode || this._mdownOnHandle || this._grabbingNode || node.isParent()) {
306 | return // don't override existing handle that's being dragged also don't trigger when grabbing a node etc
307 | }
308 |
309 | if (this._listeners.lastMdownHandler) {
310 | this._container.removeEventListener('mousedown', this._listeners.lastMdownHandler, true)
311 | this._container.removeEventListener('touchstart', this._listeners.lastMdownHandler, true)
312 | }
313 |
314 | this._lastActiveId = node.id()
315 |
316 | // remove old handle
317 | this.clearDraws()
318 |
319 | // add new handle
320 | this._drawHandle(node)
321 |
322 | node.trigger('cyeditor.noderesize-showhandle')
323 | let lastPosition = {}
324 |
325 | let mdownHandler = (e) => {
326 | this._container.removeEventListener('mousedown', mdownHandler, true)
327 | this._container.removeEventListener('touchstart', mdownHandler, true)
328 |
329 | let pageX = !e.touches ? e.pageX : e.touches[ 0 ].pageX
330 | let pageY = !e.touches ? e.pageY : e.touches[ 0 ].pageY
331 | let x = pageX - utils.offset(this._container).left
332 | let y = pageY - utils.offset(this._container).top
333 | lastPosition.x = x
334 | lastPosition.y = y
335 |
336 | if (e.button !== 0 && !e.touches) {
337 | return // sorry, no right clicks allowed
338 | }
339 |
340 | let padding = this._options.padding
341 | let rp = node.renderedPosition()
342 | let w = node.renderedOuterWidth() + padding * 2
343 | let h = node.renderedOuterHeight() + padding * 2
344 | let ts = this._options.triangleSize * this.cy.zoom()
345 |
346 | let x1 = rp.x + w / 2 - ts
347 | let y1 = rp.y + h / 2
348 | let x2 = rp.x + w / 2
349 | let y2 = rp.y + h / 2 - ts
350 |
351 | let p = { x: x, y: y }
352 | let p0 = { x: x1, y: y1 }
353 | let p1 = { x: x2, y: y2 }
354 | let p2 = { x: rp.x + w / 2, y: rp.y + h / 2 }
355 |
356 | if (!ptInTriangle(p, p0, p1, p2)) {
357 | return // only consider this a proper mousedown if on the handle
358 | }
359 |
360 | node.addClass('noderesize-resized')
361 |
362 | this._mdownOnHandle = true
363 |
364 | e.preventDefault()
365 | e.stopPropagation()
366 |
367 | this.sourceNode = node
368 |
369 | node.trigger('cyeditor.noderesize-start')
370 | let originalSize = {
371 | width: node.width(),
372 | height: node.height()
373 | }
374 |
375 | let doneMoving = (dmEvent) => {
376 | if (!this._mdownOnHandle) {
377 | return
378 | }
379 |
380 | this._mdownOnHandle = false
381 | window.removeEventListener('mousemove', moveHandler)
382 | window.removeEventListener('touchmove', moveHandler)
383 | this._resetToDefaultState()
384 |
385 | this._options.stop(node)
386 | node.trigger('cyeditor.noderesize-stop')
387 | this.cy.trigger('cyeditor.noderesize-resized',
388 | [
389 | node,
390 | originalSize,
391 | {
392 | width: node.width(),
393 | height: node.height()
394 | }
395 | ]
396 | )
397 | }
398 |
399 | [ 'mouseup', 'touchend', 'touchcancel', 'blur' ].forEach(function (e) {
400 | utils.once(window, e, doneMoving)
401 | })
402 | window.addEventListener('mousemove', moveHandler)
403 | window.addEventListener('touchmove', moveHandler)
404 | this._disableGestures()
405 | this._options.start(node)
406 |
407 | return false
408 | }
409 |
410 | let moveHandler = (e) => {
411 | let pageX = !e.touches ? e.pageX : e.touches[ 0 ].pageX
412 | let pageY = !e.touches ? e.pageY : e.touches[ 0 ].pageY
413 | let x = pageX - utils.offset(this._container).left
414 | let y = pageY - utils.offset(this._container).top
415 |
416 | let dx = x - lastPosition.x
417 | let dy = y - lastPosition.y
418 |
419 | lastPosition.x = x
420 | lastPosition.y = y
421 | let keepAspectRatio = e.ctrlKey
422 | let w = node.data('width') || node.width()
423 | let h = node.data('height') || node.height()
424 |
425 | if (keepAspectRatio) {
426 | let aspectRatio = w / h
427 | if (dy === 0) {
428 | dy = dx = dx * aspectRatio
429 | } else {
430 | dx = dy = (dy < 0 ? Math.min(dx, dy) : Math.max(dx, dy)) * aspectRatio
431 | }
432 | }
433 | dx /= this.cy.zoom()
434 | dy /= this.cy.zoom()
435 |
436 | node.data('width', Math.max(w + dx * 2, this._options.minNodeWidth))
437 | node.data('height', Math.max(h + dy * 2, this._options.minNodeHeight))
438 |
439 | this.cy.trigger('cyeditor.noderesize-resizing', [ node, {
440 | width: node.width(),
441 | height: node.height()
442 | } ])
443 |
444 | this.clearDraws()
445 | this._drawHandle(node)
446 |
447 | return false
448 | }
449 |
450 | this._container.addEventListener('mousedown', mdownHandler, true)
451 | this._container.addEventListener('touchstart', mdownHandler, true)
452 | this._listeners.lastMdownHandler = mdownHandler
453 | }
454 | }
455 |
456 | export default (cytoscape) => {
457 | if (!cytoscape) { return }
458 |
459 | cytoscape('core', 'noderesize', function (options) {
460 | return new NodeResize(this, options)
461 | })
462 | }
463 |
--------------------------------------------------------------------------------
/src/lib/cyeditor-snap-grid/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by DemonRay on 2019/3/21.
3 | */
4 | import utils from '../../utils'
5 |
6 | class SnapToGrid {
7 | constructor (cy, params) {
8 | this.cy = cy
9 | let defaults = {
10 | stackOrder: -1,
11 | gridSpacing: 35,
12 | strokeStyle: '#CCCCCC',
13 | lineWidth: 1.0,
14 | lineDash: [ 5, 8 ],
15 | zoomDash: true,
16 | panGrid: true,
17 | snapToGrid: true,
18 | drawGrid: true
19 | }
20 | this._options = utils.extend(true, {}, defaults, params)
21 | this._init()
22 | this._initEvents()
23 | }
24 |
25 | _init () {
26 | this._container = this.cy.container()
27 | this.canvas = document.createElement('canvas')
28 | this.ctx = this.canvas.getContext('2d')
29 | this._container.append(this.canvas)
30 | }
31 |
32 | _initEvents () {
33 | window.addEventListener('resize', () => this._resizeCanvas())
34 |
35 | this.cy.ready(() => {
36 | this._resizeCanvas()
37 | if (this._options.snapToGrid) {
38 | this.snapAll()
39 | }
40 | this.cy.on('zoom', () => this._drawGrid())
41 | this.cy.on('pan', () => this._drawGrid())
42 | this.cy.on('free', (e) => this._nodeFreed(e))
43 | this.cy.on('add', (e) => this._nodeAdded(e))
44 | })
45 | }
46 |
47 | _resizeCanvas () {
48 | let rect = this._container.getBoundingClientRect()
49 | this.canvas.height = rect.height
50 | this.canvas.width = rect.width
51 | this.canvas.style.position = 'absolute'
52 | this.canvas.style.top = 0
53 | this.canvas.style.left = 0
54 | this.canvas.style.zIndex = this._options.stackOrder
55 |
56 | setTimeout(() => {
57 | let canvasBb = utils.offset(this.canvas)
58 | let containerBb = utils.offset(this._container)
59 | this.canvas.style.top = -(canvasBb.top - containerBb.top)
60 | this.canvas.style.left = -(canvasBb.left - containerBb.left)
61 | this._drawGrid()
62 | }, 0)
63 | }
64 |
65 | _drawGrid () {
66 | this.clear()
67 |
68 | if (!this._options.drawGrid) {
69 | return
70 | }
71 |
72 | let zoom = this.cy.zoom()
73 | let rect = this._container.getBoundingClientRect()
74 | let canvasWidth = rect.width
75 | let canvasHeight = rect.height
76 | let increment = this._options.gridSpacing * zoom
77 | let pan = this.cy.pan()
78 | let initialValueX = pan.x % increment
79 | let initialValueY = pan.y % increment
80 |
81 | this.ctx.strokeStyle = this._options.strokeStyle
82 | this.ctx.lineWidth = this._options.lineWidth
83 |
84 | if (this._options.zoomDash) {
85 | let zoomedDash = this._options.lineDash.slice()
86 |
87 | for (let i = 0; i < zoomedDash.length; i++) {
88 | zoomedDash[ i ] = this._options.lineDash[ i ] * zoom
89 | }
90 | this.ctx.setLineDash(zoomedDash)
91 | } else {
92 | this.ctx.setLineDash(this._options.lineDash)
93 | }
94 |
95 | if (this._options.panGrid) {
96 | this.ctx.lineDashOffset = -pan.y
97 | } else {
98 | this.ctx.lineDashOffset = 0
99 | }
100 |
101 | for (let i = initialValueX; i < canvasWidth; i += increment) {
102 | this.ctx.beginPath()
103 | this.ctx.moveTo(i, 0)
104 | this.ctx.lineTo(i, canvasHeight)
105 | this.ctx.stroke()
106 | }
107 |
108 | if (this._options.panGrid) {
109 | this.ctx.lineDashOffset = -pan.x
110 | } else {
111 | this.ctx.lineDashOffset = 0
112 | }
113 |
114 | for (let i = initialValueY; i < canvasHeight; i += increment) {
115 | this.ctx.beginPath()
116 | this.ctx.moveTo(0, i)
117 | this.ctx.lineTo(canvasWidth, i)
118 | this.ctx.stroke()
119 | }
120 | }
121 |
122 | _nodeFreed (ev) {
123 | if (this._options.snapToGrid) {
124 | this.snapNode(ev.target)
125 | }
126 | }
127 |
128 | _nodeAdded (ev) {
129 | if (this._options.snapToGrid) {
130 | this.snapNode(ev.target)
131 | }
132 | }
133 | snapNode (node) {
134 | let pos = node.position()
135 |
136 | let cellX = Math.floor(pos.x / this._options.gridSpacing)
137 | let cellY = Math.floor(pos.y / this._options.gridSpacing)
138 |
139 | node.position({
140 | x: (cellX + 0.5) * this._options.gridSpacing,
141 | y: (cellY + 0.5) * this._options.gridSpacing
142 | })
143 | }
144 |
145 | snapAll () {
146 | this.cy.nodes().each((node) => {
147 | this.snapNode(node)
148 | })
149 | }
150 |
151 | refresh () {
152 | this._resizeCanvas()
153 | }
154 |
155 | snapOn () {
156 | this._options.snapToGrid = true
157 | this.snapAll()
158 | this.cy.trigger('cyeditor.snapgridon')
159 | }
160 |
161 | snapOff () {
162 | this._options.snapToGrid = false
163 | this.cy.trigger('cyeditor.snapgridoff')
164 | }
165 |
166 | gridOn () {
167 | this._options.drawGrid = true
168 | this._drawGrid()
169 | this.cy.trigger('cyeditor.gridon')
170 | }
171 |
172 | gridOff () {
173 | this._options.drawGrid = false
174 | this._drawGrid()
175 | this.cy.trigger('cyeditor.gridoff')
176 | }
177 |
178 | clear () {
179 | let rect = this._container.getBoundingClientRect()
180 | let width = rect.width
181 | let height = rect.height
182 |
183 | this.ctx.clearRect(0, 0, width, height)
184 | }
185 | }
186 |
187 | export default (cytoscape) => {
188 | if (!cytoscape) { return }
189 |
190 | cytoscape('core', 'snapToGrid', function (options) {
191 | return new SnapToGrid(this, options)
192 | })
193 | }
194 |
--------------------------------------------------------------------------------
/src/lib/cyeditor-toolbar/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by DemonRay on 2019/3/28.
3 | */
4 | import utils from '../../utils'
5 |
6 | let defaults = {
7 | container: false,
8 | commands: [
9 | { command: 'undo', icon: 'icon-undo', disabled: true, title: utils.localize('toolbar-undo') },
10 | { command: 'redo', icon: 'icon-Redo', disabled: true, title: utils.localize('toolbar-redo') },
11 | { command: 'zoomin', icon: 'icon-zoomin', disabled: false, title: utils.localize('toolbar-zoomin'), separator: true },
12 | { command: 'zoomout', icon: 'icon-zoom', disabled: false, title: utils.localize('toolbar-zoomout') },
13 | { command: 'boxselect', icon: 'icon-selection', disabled: false, title: utils.localize('toolbar-boxselect'), selected: false },
14 | { command: 'copy', icon: 'icon-copy', disabled: true, title: utils.localize('toolbar-copy'), separator: true },
15 | { command: 'paste', icon: 'icon-paste', disabled: true, title: utils.localize('toolbar-paste') },
16 | { command: 'delete', icon: 'icon-delete', disabled: true, title: utils.localize('toolbar-delete') },
17 | { command: 'leveldown', icon: 'icon-arrow-to-bottom', disabled: true, title: utils.localize('toolbar-leveldown') },
18 | { command: 'levelup', icon: 'icon-top-arrow-from-top', disabled: true, title: utils.localize('toolbar-levelup') },
19 | { command: 'line-straight', icon: 'icon-Line-Tool', disabled: false, title: utils.localize('toolbar-line-straight'), selected: false, separator: true },
20 | { command: 'line-taxi', icon: 'icon-gongzuoliuchengtu', disabled: false, title: utils.localize('toolbar-line-taxi'), selected: false },
21 | { command: 'line-bezier', icon: 'icon-Bezier-', disabled: false, title: utils.localize('toolbar-line-bezier'), selected: false },
22 | { command: 'gridon', icon: 'icon-grid', disabled: false, title: utils.localize('toolbar-gridon'), selected: false, separator: true },
23 | { command: 'fit', icon: 'icon-fullscreen', disabled: false, title: utils.localize('toolbar-fit') },
24 | { command: 'save', icon: 'icon-save', disabled: false, title: utils.localize('toolbar-save'), separator: true }
25 | ]
26 | }
27 | class Toolbar {
28 | constructor (cy, params) {
29 | this.cy = cy
30 | this._init(params)
31 | this._listeners = {}
32 | this._initEvents()
33 | }
34 |
35 | _init (params) {
36 | this._options = Object.assign({}, defaults, params)
37 | if (Array.isArray(this._options.toolbar)) {
38 | this._options.commands = this._options.commands.filter(item => this._options.toolbar.indexOf(item.command) > -1)
39 | }
40 |
41 | this._initShapePanel()
42 | }
43 |
44 | _initEvents () {
45 | this._listeners.command = (e) => {
46 | let command = e.target.getAttribute('data-command')
47 | if (!command) { return }
48 | let commandOpt = this._options.commands.find(it => it.command === command)
49 | if (['boxselect', 'gridon'].indexOf(command) > -1) {
50 | this.rerender(command, { selected: !commandOpt.selected })
51 | } else if (['line-straight', 'line-bezier', 'line-taxi'].indexOf(command) > -1) {
52 | this.rerender('line-straight', { selected: command === 'line-straight' })
53 | this.rerender('line-bezier', { selected: command === 'line-bezier' })
54 | this.rerender('line-taxi', { selected: command === 'line-taxi' })
55 | } else if (command === 'fit') {
56 | this.rerender('fit', { icon: commandOpt.icon === 'icon-fullscreen' ? 'icon-fullscreen-exit' : 'icon-fullscreen' })
57 | }
58 | if (commandOpt) {
59 | this.cy.trigger('cyeditor.toolbar-command', commandOpt)
60 | }
61 | }
62 | this._panel.addEventListener('click', this._listeners.command)
63 | this._listeners.select = this._selectChange.bind(this)
64 | this.cy.on('select unselect', this._listeners.select)
65 | }
66 |
67 | _selectChange () {
68 | let selected = this.cy.$(':selected')
69 | if (selected && selected.length !== this._last_selected_length) {
70 | let hasSelected = selected.length > 0
71 | this._options.commands.forEach(item => {
72 | if ([ 'delete', 'copy', 'leveldown', 'levelup' ].indexOf(item.command) > -1) {
73 | item.disabled = !hasSelected
74 | }
75 | })
76 | this._panelHtml()
77 | }
78 | this._last_selected_length = selected
79 | }
80 |
81 | _initShapePanel () {
82 | let { _options } = this
83 | if (_options.container) {
84 | if (typeof _options.container === 'string') {
85 | this._panel = utils.query(_options.container)[ 0 ]
86 | } else if (utils.isNode(_options.container)) {
87 | this._panel = _options.container
88 | }
89 | if (!this._panel) {
90 | console.error('There is no any element matching your container')
91 | return
92 | }
93 | } else {
94 | this._panel = document.createElement('div')
95 | document.body.appendChild(this._panel)
96 | }
97 | this._panelHtml()
98 | }
99 |
100 | _panelHtml () {
101 | let icons = ''
102 | this._options.commands.forEach(({ command, title, icon, disabled, selected, separator }) => {
103 | let cls = `${icon} ${disabled ? 'disable' : ''} ${selected === true ? 'selected' : ''}`
104 | if (separator) icons += '
131 | ${navigatorDom}
132 | ${infoDom}
133 |
`
134 | }
135 | domHtml += `