@convertkit/selection-toolbar
Advanced tools
Comparing version 0.1.8 to 0.1.9
{ | ||
"name": "@convertkit/selection-toolbar", | ||
"version": "0.1.8", | ||
"version": "0.1.9", | ||
"description": "A toolbar for the ConvertKit Editor that hovers over the selection.", | ||
@@ -35,3 +35,3 @@ "main": "dist/index.js", | ||
}, | ||
"gitHead": "251b4984e9f6e5558656f507400c994ce15553b9" | ||
"gitHead": "8db86681a3d9ac14d4a3954a492acb3e63bbf6f5" | ||
} |
@@ -1,176 +0,111 @@ | ||
import React, { Component } from "react"; | ||
import PropTypes from "prop-types"; | ||
import React, { useRef, useState, useCallback } from "react"; | ||
import classNames from "classnames"; | ||
import { isTextSelected } from "./utils"; | ||
import PositionElement from "../../../shared/components/position-element"; | ||
import SelectionRect from "./selection-rect"; | ||
import { | ||
isTextSelected, | ||
useDebounce, | ||
useTransitionElement, | ||
useEventListener | ||
} from "./utils"; | ||
import { useSelectionPosition } from "./use-selection"; | ||
import Toolbar from "./toolbar"; | ||
import { trailingDebounce } from "./utils"; | ||
import "./selection-toolbar.css"; | ||
import isHotkey from "is-hotkey"; | ||
const wrapRect = rect => ({ getBoundingClientRect: () => rect }); | ||
function SelectionToolbar({ editor, buttons, exclude, style }) { | ||
const toolbar = useRef(); | ||
const transition = (element, fn, callback) => { | ||
function listener() { | ||
callback(); | ||
element.removeEventListener("transitionend", listener); | ||
} | ||
element.addEventListener("transitionend", listener); | ||
fn(); | ||
}; | ||
const [transitioning, startTransition] = useTransitionElement( | ||
toolbar.current | ||
); | ||
class SelectionToolbar extends Component { | ||
static propTypes = { | ||
buttons: PropTypes.array.isRequired, | ||
exclude: PropTypes.array, | ||
styles: PropTypes.object.isRequired | ||
const [visible, setVisible] = useState(false); | ||
const [menu, setMenu] = useState(null); | ||
const openMenu = menu => { | ||
setMenu(menu); | ||
}; | ||
static defaultProps = { | ||
buttons: [], | ||
styles: {} | ||
const closeMenu = () => { | ||
setMenu(null); | ||
}; | ||
state = { visible: false, menu: null }; | ||
toolbar = React.createRef(); | ||
const { styles, arrowStyles } = useSelectionPosition(toolbar.current, { | ||
placement: "top", | ||
debounce: menu ? -1 : visible ? 300 : 100 | ||
}); | ||
componentDidMount() { | ||
document.addEventListener("selectionchange", this.debouncedUpdate); | ||
document.addEventListener("keydown", this.handleKeyDown); | ||
} | ||
componentWillUnmount() { | ||
document.removeEventListener("selectionchange", this.debouncedUpdate); | ||
} | ||
update = () => { | ||
const visible = this.state.visible; | ||
const menuOpen = this.state.menu != undefined; | ||
const textSelected = isTextSelected(this.props.editor.value); | ||
const shouldShow = textSelected && !visible; | ||
const shouldHide = !textSelected && !menuOpen && visible; | ||
if (shouldShow) return this.show(); | ||
if (shouldHide) return this.hide(); | ||
const show = () => { | ||
startTransition(); | ||
setVisible(true); | ||
}; | ||
debouncedUpdate = trailingDebounce(this.update, 200); | ||
show = () => { | ||
if (!this.toolbar.current) return; | ||
transition( | ||
this.toolbar.current, | ||
() => { | ||
this.setState({ visible: true, transitioning: true }); | ||
}, | ||
this.transitionEnd | ||
); | ||
const hide = () => { | ||
startTransition(); | ||
setVisible(false); | ||
closeMenu(); | ||
}; | ||
hide = () => { | ||
if (!this.toolbar.current) return; | ||
transition( | ||
this.toolbar.current, | ||
() => { | ||
this.setState({ visible: false, transitioning: true }); | ||
}, | ||
() => { | ||
this.transitionEnd(); | ||
this.closeMenu(); | ||
} | ||
); | ||
}; | ||
const textSelected = isTextSelected(editor.value); | ||
transitionEnd = () => { | ||
this.setState({ transitioning: false }); | ||
}; | ||
const [handleSelectionChange] = useDebounce( | ||
() => { | ||
const shouldShow = textSelected && !visible; | ||
const shouldHide = !textSelected && !menu; | ||
handleMouseDown = e => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
}; | ||
if (shouldShow) return show(); | ||
if (shouldHide) return hide(); | ||
}, | ||
200, | ||
[textSelected, visible, menu] | ||
); | ||
handleKeyDown = e => { | ||
e.stopPropagation(); | ||
const handleKeyDown = event => { | ||
event.stopPropagation(); | ||
if (!this.state.visible) return; | ||
if (!visible) return; | ||
this.props.buttons.find((button, index) => { | ||
if (!button.shortcut) return; | ||
buttons.find((button, index) => { | ||
if (!button.shortcut) return false; | ||
if (isHotkey(button.shortcut, e)) { | ||
this.setState({ menu: index }); | ||
if (isHotkey(button.shortcut, event)) { | ||
openMenu(index); | ||
return true; | ||
} | ||
return false; | ||
}); | ||
}; | ||
openMenu = menu => { | ||
this.setState({ menu }); | ||
}; | ||
useEventListener(document, "selectionchange", handleSelectionChange); | ||
useEventListener(window, "resize", handleSelectionChange); | ||
useEventListener(document, "keydown", handleKeyDown); | ||
closeMenu = () => { | ||
this.setState({ menu: null }); | ||
}; | ||
const className = classNames("convertkit-editor-selection-toolbar", { | ||
visible, | ||
active: isTextSelected(editor.value), | ||
transitioning | ||
}); | ||
excludedBlock = () => | ||
this.props.editor.value.blocks.some(block => | ||
this.props.exclude.includes(block.type) | ||
); | ||
render() { | ||
const { buttons, editor, exclude, styles } = this.props; | ||
const { menu, transitioning, visible } = this.state; | ||
const { button: buttonStyle, ...toolbarStyle } = styles; | ||
if (this.excludedBlock()) { | ||
return null; | ||
} | ||
const className = classNames("convertkit-editor-selection-toolbar", { | ||
visible, | ||
transitioning | ||
}); | ||
const menuOpen = menu != undefined; | ||
const freezePosition = transitioning || menuOpen; | ||
const debounce = freezePosition ? -1 : visible ? 201 : 0; | ||
return ( | ||
<SelectionRect debounce={debounce}> | ||
{rect => ( | ||
<PositionElement | ||
active={this.state.visible} | ||
target={wrapRect(rect)} | ||
element={this.toolbar.current} | ||
alignX="center" | ||
alignY="top" | ||
> | ||
{({ bottom, left }) => ( | ||
<Toolbar | ||
ref={this.toolbar} | ||
buttons={buttons} | ||
editor={editor} | ||
menu={{ | ||
active: menu != null, | ||
index: menu, | ||
close: this.hide, | ||
open: this.openMenu | ||
}} | ||
className={className} | ||
style={{ | ||
bottom: bottom + 8, | ||
left, | ||
...toolbarStyle | ||
}} | ||
/> | ||
)} | ||
</PositionElement> | ||
)} | ||
</SelectionRect> | ||
); | ||
} | ||
return ( | ||
<Toolbar | ||
ref={toolbar} | ||
className={className} | ||
buttons={buttons} | ||
editor={editor} | ||
menu={{ | ||
active: menu != null, | ||
index: menu, | ||
close: hide, | ||
open: openMenu | ||
}} | ||
style={{ | ||
...styles | ||
}} | ||
arrowStyle={arrowStyles} | ||
/> | ||
); | ||
} | ||
export default SelectionToolbar; |
@@ -6,3 +6,3 @@ import React from "react"; | ||
export default React.forwardRef(function Toolbar( | ||
{ buttons, editor, menu, className, style }, | ||
{ arrow, buttons, editor, menu, className, style, arrowStyle }, | ||
ref | ||
@@ -36,4 +36,9 @@ ) { | ||
</div> | ||
<div | ||
ref={arrow} | ||
style={{ left: arrowStyle.left || 0 }} | ||
className="selection-toolbar-arrow" | ||
/> | ||
</div> | ||
); | ||
}); |
134
src/utils.js
@@ -1,22 +0,124 @@ | ||
export const transition = (element, fn, callback) => { | ||
function listener() { | ||
callback(); | ||
element.removeEventListener("transitionend", listener); | ||
} | ||
element.addEventListener("transitionend", listener); | ||
fn(); | ||
import { useCallback, useEffect, useRef, useState } from "react"; | ||
/** | ||
* Calls the function `debounce` milliseconds after the last time the function | ||
* is called (trailing debounce). Any change to deps will reset the timer, not | ||
* calling the function. | ||
* | ||
* @param {function} callback | ||
* @param {integer} debounce | ||
* @param {array} deps | ||
*/ | ||
export const useDebounce = (callback, debounce, deps) => { | ||
const timeout = useRef(); | ||
const cb = useRef(); | ||
useEffect(() => { | ||
cb.current = callback; | ||
}, [callback]); | ||
const cancel = useCallback(() => { | ||
clearTimeout(timeout.current); | ||
}, [timeout.current]); | ||
useEffect(() => { | ||
cancel(); | ||
}, [debounce]); | ||
const debounced = useCallback( | ||
(...args) => { | ||
cancel(); | ||
timeout.current = setTimeout(() => { | ||
cb.current(...args); | ||
}, debounce); | ||
}, | ||
[debounce, ...deps] | ||
); | ||
return [debounced]; | ||
}; | ||
export const trailingDebounce = (fn, time) => { | ||
let timeout; | ||
return function() { | ||
const args = arguments; | ||
const context = this; | ||
clearTimeout(timeout); | ||
timeout = setTimeout(() => { | ||
fn.apply(context, args); | ||
}, time); | ||
/** | ||
* Used to declaratively determine if an element is in a CSS transition. | ||
* | ||
* Example: | ||
* CSS | ||
* .block { | ||
* left: 0px; | ||
* transition: left 300ms ease-in-out; | ||
* } | ||
* | ||
* .block.transitioning { | ||
* background: red | ||
* } | ||
* | ||
* function Demo() { | ||
* const [left, setLeft] = useState(0) | ||
* const [transitioning, start] = useTransitionElement(elementReft) | ||
* | ||
* const moveLeft = () => { | ||
* start() | ||
* setLeft(left + 100) | ||
* } | ||
* | ||
* let className = "block" | ||
* if (transitioning) className += " transitioning" | ||
* | ||
* return ( | ||
* <button className="transitioning" style={{ left }} onClick={moveLeft}> | ||
* Move Left | ||
* </button> | ||
* ) | ||
* } | ||
* | ||
* In this example, the "transitioning" class will be on the element after | ||
* clicking start for the transition period specified in the CSS, or 500ms, | ||
* whichever is shorter. | ||
*/ | ||
export const useTransitionElement = (element, timeout = 500) => { | ||
const [transitioning, setTransitioning] = useState(false); | ||
const start = () => { | ||
setTransitioning(true); | ||
setTimeout(() => { | ||
setTransitioning(false); | ||
}, timeout); | ||
}; | ||
const end = () => { | ||
setTransitioning(false); | ||
}; | ||
useEffect(() => { | ||
if (!element) return; | ||
element.addEventListener("transitionend", end); | ||
return () => { | ||
element.removeEventListener("transitionend", end); | ||
}; | ||
}, [element]); | ||
if (!element) return [false, start]; | ||
return [transitioning, start]; | ||
}; | ||
/** | ||
* A convenience method for adding and cleaning up event listeners. | ||
* | ||
* @param {DOMElement} target | ||
* @param {string} event | ||
* @param {function} callback | ||
*/ | ||
export const useEventListener = (target, event, callback) => { | ||
return useEffect(() => { | ||
target.addEventListener(event, callback); | ||
return () => { | ||
target.removeEventListener(event, callback); | ||
}; | ||
}, [callback]); | ||
}; | ||
export const isTextSelected = value => { | ||
@@ -23,0 +125,0 @@ const { fragment, selection } = value; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
15
1
0
70300
2430