visbug-lib
Version:
<p align="center"> <img src="./assets/visbug.png" width="300" height="300" alt="visbug"> <br> <a href="https://www.npmjs.org/package/visbug"><img src="https://img.shields.io/npm/v/visbug.svg?style=flat" alt="npm latest version number"></a> <a href
743 lines (601 loc) • 20.5 kB
JavaScript
import $ from 'blingblingjs'
import hotkeys from 'hotkeys-js'
import { TinyColor } from '@ctrl/tinycolor'
import { canMoveLeft, canMoveRight, canMoveUp } from './move'
import { watchImagesForUpload } from './imageswap'
import { queryPage } from './search'
import { createMeasurements, clearMeasurements } from './measurements'
import { createMarginVisual } from './margin'
import { createPaddingVisual } from './padding'
import { showTip as showMetaTip, removeAll as removeAllMetaTips } from './metatip'
import { showTip as showAccessibilityTip, removeAll as removeAllAccessibilityTips } from './accessibility'
import {
metaKey, htmlStringToDom, createClassname, camelToDash,
isOffBounds, getStyles, deepElementFromPoint, getShadowValues,
isSelectorValid, findNearestChildElement, findNearestParentElement,
getTextShadowValues
} from '../utilities/'
export function Selectable(visbug) {
const page = document.body
let selected = []
let selectedCallbacks = []
let labels = []
let handles = []
const hover_state = {
target: null,
element: null,
label: null,
}
const listen = () => {
page.addEventListener('click', on_click, true)
page.addEventListener('dblclick', on_dblclick, true)
page.on('selectstart', on_selection)
page.on('mousemove', on_hover)
document.addEventListener('copy', on_copy)
document.addEventListener('cut', on_cut)
document.addEventListener('paste', on_paste)
watchCommandKey()
hotkeys(`${metaKey}+alt+c`, on_copy_styles)
hotkeys(`${metaKey}+alt+v`, e => on_paste_styles())
hotkeys('esc', on_esc)
hotkeys(`${metaKey}+d`, on_duplicate)
hotkeys('backspace,del,delete', on_delete)
hotkeys('alt+del,alt+backspace', on_clearstyles)
hotkeys(`${metaKey}+e,${metaKey}+shift+e`, on_expand_selection)
hotkeys(`${metaKey}+g,${metaKey}+shift+g`, on_group)
hotkeys('tab,shift+tab,enter,shift+enter', on_keyboard_traversal)
hotkeys(`${metaKey}+shift+enter`, on_select_children)
}
const unlisten = () => {
page.removeEventListener('click', on_click, true)
page.removeEventListener('dblclick', on_dblclick, true)
page.off('selectstart', on_selection)
page.off('mousemove', on_hover)
document.removeEventListener('copy', on_copy)
document.removeEventListener('cut', on_cut)
document.removeEventListener('paste', on_paste)
hotkeys.unbind(`esc,${metaKey}+d,backspace,del,delete,alt+del,alt+backspace,${metaKey}+e,${metaKey}+shift+e,${metaKey}+g,${metaKey}+shift+g,tab,shift+tab,enter,shift+enter`)
}
const on_click = e => {
const $target = deepElementFromPoint(e.clientX, e.clientY)
if (isOffBounds($target) && !selected.filter(el => el == $target).length)
return
e.preventDefault()
if (!e.altKey) e.stopPropagation()
if (!e.shiftKey) {
unselect_all({silent:true})
clearMeasurements()
}
if(e.shiftKey && $target.hasAttribute('data-selected'))
unselect($target.getAttribute('data-label-id'))
else
select($target)
}
const unselect = id => {
[...labels, ...handles]
.filter(node =>
node.getAttribute('data-label-id') === id)
.forEach(node =>
node.remove())
selected.filter(node =>
node.getAttribute('data-label-id') === id)
.forEach(node =>
$(node).attr({
'data-selected': null,
'data-selected-hide': null,
'data-label-id': null,
'data-pseudo-select': null,
'data-measuring': null,
}))
selected = selected.filter(node => node.getAttribute('data-label-id') !== id)
tellWatchers()
}
const on_dblclick = e => {
e.preventDefault()
e.stopPropagation()
if (isOffBounds(e.target)) return
visbug.toolSelected('text')
}
const watchCommandKey = e => {
let did_hide = false
document.onkeydown = function(e) {
if (hotkeys.ctrl && selected.length) {
$('visbug-handles, visbug-label, visbug-hover, visbug-grip').forEach(el =>
el.style.display = 'none')
did_hide = true
}
}
document.onkeyup = function(e) {
if (did_hide) {
$('visbug-handles, visbug-label, visbug-hover, visbug-grip').forEach(el =>
el.style.display = null)
did_hide = false
}
}
}
const on_esc = _ =>
unselect_all()
const on_duplicate = e => {
const root_node = selected[0]
if (!root_node) return
const deep_clone = root_node.cloneNode(true)
deep_clone.removeAttribute('data-selected')
root_node.parentNode.insertBefore(deep_clone, root_node.nextSibling)
e.preventDefault()
}
const on_delete = e =>
selected.length && delete_all()
const on_clearstyles = e =>
selected.forEach(el =>
el.attr('style', null))
const on_copy = async e => {
// if user has selected text, dont try to copy an element
if (window.getSelection().toString().length)
return
if (selected[0] && this.node_clipboard !== selected[0]) {
e.preventDefault()
let $node = selected[0].cloneNode(true)
$node.removeAttribute('data-selected')
this.copy_backup = $node.outerHTML
e.clipboardData.setData('text/html', this.copy_backup)
const {state} = await navigator.permissions.query({name:'clipboard-write'})
if (state === 'granted')
await navigator.clipboard.writeText(this.copy_backup)
}
}
const on_cut = e => {
if (selected[0] && this.node_clipboard !== selected[0]) {
let $node = selected[0].cloneNode(true)
$node.removeAttribute('data-selected')
this.copy_backup = $node.outerHTML
e.clipboardData.setData('text/html', this.copy_backup)
selected[0].remove()
}
}
const on_paste = async (e, index = 0) => {
const clipData = e.clipboardData.getData('text/html')
const globalClipboard = await navigator.clipboard.readText()
const potentialHTML = clipData || globalClipboard || this.copy_backup
if (selected.length && potentialHTML) {
e.preventDefault()
selected.forEach(el =>
el.appendChild(
htmlStringToDom(potentialHTML)))
}
}
const on_copy_styles = async e => {
e.preventDefault()
this.copied_styles = selected.map(el =>
getStyles(el))
try {
const colormode = visbug.colorMode
const styles = this.copied_styles[0]
.map(({prop,value}) => {
if (prop.includes('color') || prop.includes('background-color') || prop.includes('border-color') || prop.includes('Color') || prop.includes('fill') || prop.includes('stroke'))
value = new TinyColor(value)[colormode]()
if (prop.includes('boxShadow')) {
const [, color, x, y, blur, spread] = getShadowValues(value)
value = `${new TinyColor(color)[colormode]()} ${x} ${y} ${blur} ${spread}`
}
if (prop.includes('textShadow')) {
const [, color, x, y, blur] = getTextShadowValues(value)
value = `${new TinyColor(color)[colormode]()} ${x} ${y} ${blur}`
}
return {prop,value}
})
.reduce((message, item) =>
[...message, `${camelToDash(item.prop)}: ${item.value};`]
, []).join('\n')
const {state} = await navigator.permissions.query({name:'clipboard-write'})
if (styles && state === 'granted') {
await navigator.clipboard.writeText(styles)
console.info('copied!')
}
} catch(e) {
console.warn(e)
}
}
const on_paste_styles = async (e, index = 0) => {
if (this.copied_styles) {
selected.forEach(el => {
this.copied_styles[index]
.map(({prop, value}) =>
el.style[prop] = value)
index >= this.copied_styles.length - 1
? index = 0
: index++
})
}
else {
const potentialStyles = await navigator.clipboard.readText()
if (selected.length && potentialStyles)
selected.forEach(el =>
el.style = potentialStyles)
}
}
const on_expand_selection = (e, {key}) => {
e.preventDefault()
const [root] = selected
if (!root) return
const query = combineNodeNameAndClass(root)
if (isSelectorValid(query))
expandSelection({
query,
all: key.includes('shift'),
})
}
const on_group = (e, {key}) => {
e.preventDefault()
if (key.split('+').includes('shift')) {
let $selected = [...selected]
unselect_all()
$selected.reverse().forEach(el => {
let l = el.children.length
while (el.children.length > 0) {
var node = el.childNodes[el.children.length - 1]
if (node.nodeName !== '#text')
select(node)
el.parentNode.prepend(node)
}
el.parentNode.removeChild(el)
})
}
else {
let div = document.createElement('div')
selected[0].parentNode.prepend(
selected.reverse().reduce((div, el) => {
div.appendChild(el)
return div
}, div)
)
unselect_all()
select(div)
}
}
const on_selection = e =>
!isOffBounds(e.target)
&& selected.length
&& selected[0].textContent != e.target.textContent
&& e.preventDefault()
const on_keyboard_traversal = (e, {key}) => {
if (!selected.length) return
e.preventDefault()
e.stopPropagation()
const targets = selected.reduce((flat_n_unique, node) => {
const element_to_left = canMoveLeft(node)
const element_to_right = canMoveRight(node)
const has_parent_element = findNearestParentElement(node)
const has_child_elements = findNearestChildElement(node)
if (key.includes('shift')) {
if (key.includes('tab') && element_to_left)
flat_n_unique.add(element_to_left)
else if (key.includes('enter') && has_parent_element)
flat_n_unique.add(has_parent_element)
else
flat_n_unique.add(node)
}
else {
if (key.includes('tab') && element_to_right)
flat_n_unique.add(element_to_right)
else if (key.includes('enter') && has_child_elements)
flat_n_unique.add(has_child_elements)
else
flat_n_unique.add(node)
}
return flat_n_unique
}, new Set())
if (targets.size) {
unselect_all({silent:true})
targets.forEach(node => {
select(node)
show_tip(node)
})
}
}
const show_tip = el => {
const active_tool = visbug.activeTool
let tipFactory
if (active_tool === 'accessibility') {
removeAllAccessibilityTips()
tipFactory = showAccessibilityTip
}
else if (active_tool === 'inspector') {
removeAllMetaTips()
tipFactory = showMetaTip
}
if (!tipFactory) return
const {top, left} = el.getBoundingClientRect()
const { pageYOffset, pageXOffset } = window
tipFactory(el, {
clientY: top,
clientX: left,
pageY: pageYOffset + top - 10,
pageX: pageXOffset + left + 20,
})
}
const on_hover = e => {
const $target = deepElementFromPoint(e.clientX, e.clientY)
const tool = visbug.activeTool
if (isOffBounds($target) || $target.hasAttribute('data-selected') || $target.hasAttribute('draggable')) {
clearMeasurements()
return clearHover()
}
overlayHoverUI({
el: $target,
// no_hover: tool === 'guides',
no_label: tool !== 'guides',
})
if (tool === 'guides' && selected.length >= 1 && !selected.includes($target)) {
$target.setAttribute('data-measuring', true)
const [$anchor] = selected
createMeasurements({$anchor, $target})
}
else if (tool === 'margin' && !hover_state.element.$shadow.querySelector('visbug-boxmodel')) {
hover_state.element.$shadow.appendChild(
createMarginVisual(hover_state.target, true))
}
else if (tool === 'padding' && !hover_state.element.$shadow.querySelector('visbug-boxmodel')) {
hover_state.element.$shadow.appendChild(
createPaddingVisual(hover_state.target, true))
}
else if ($target.hasAttribute('data-measuring') || selected.includes($target)) {
clearMeasurements()
}
}
const select = el => {
const id = handles.length
const tool = visbug.activeTool
el.setAttribute('data-selected', true)
el.setAttribute('data-label-id', id)
clearHover()
overlayMetaUI({
el,
id,
no_label: tool !== 'inspector' && tool !== 'accessibility',
})
selected.unshift(el)
tellWatchers()
}
const selection = () =>
selected
const unselect_all = ({silent = false} = {}) => {
selected
.forEach(el =>
$(el).attr({
'data-selected': null,
'data-selected-hide': null,
'data-label-id': null,
'data-pseudo-select': null,
}))
$('[data-pseudo-select]').forEach(hover =>
hover.removeAttribute('data-pseudo-select'))
Array.from([
...$('visbug-handles'),
...$('visbug-label'),
...$('visbug-hover'),
...$('visbug-distance'),
]).forEach(el =>
el.remove())
labels = []
handles = []
selected = []
!silent && tellWatchers()
}
const delete_all = () => {
const selected_after_delete = selected.map(el => {
if (canMoveRight(el)) return canMoveRight(el)
else if (canMoveLeft(el)) return canMoveLeft(el)
else if (el.parentNode) return el.parentNode
})
Array.from([...selected, ...labels, ...handles]).forEach(el =>
el.remove())
labels = []
handles = []
selected = []
selected_after_delete.forEach(el =>
select(el))
}
const expandSelection = ({query, all = false}) => {
if (all) {
const unselecteds = $(query + ':not([data-selected])')
unselecteds.forEach(select)
}
else {
const potentials = $(query)
if (!potentials) return
const [anchor] = selected
const root_node_index = potentials.reduce((index, node, i) =>
node == anchor
? index = i
: index
, null)
if (root_node_index !== null) {
if (!potentials[root_node_index + 1]) {
const potential = potentials.filter(el => !el.attr('data-selected'))[0]
if (potential) select(potential)
}
else {
select(potentials[root_node_index + 1])
}
}
}
}
const combineNodeNameAndClass = node =>
`${node.nodeName.toLowerCase()}${createClassname(node)}`
const overlayHoverUI = ({el, no_hover = false, no_label = true}) => {
if (hover_state.target === el) return
hover_state.target = el
hover_state.element = no_hover
? null
: createHover(el)
hover_state.label = no_label
? null
: createHoverLabel(el, `
<a node>${el.nodeName.toLowerCase()}</a>
<a>${el.id && '#' + el.id}</a>
${createClassname(el).split('.')
.filter(name => name != '')
.reduce((links, name) => `
${links}
<a>.${name}</a>
`, '')
}
`)
}
const clearHover = () => {
if (!hover_state.target) return
hover_state.element && hover_state.element.remove()
hover_state.label && hover_state.label.remove()
hover_state.target = null
hover_state.element = null
hover_state.label = null
}
const overlayMetaUI = ({el, id, no_label = true}) => {
let handle = createHandle({el, id})
let label = no_label
? null
: createLabel({
el,
id,
template: `
<a node>${el.nodeName.toLowerCase()}</a>
<a>${el.id && '#' + el.id}</a>
${createClassname(el).split('.')
.filter(name => name != '')
.reduce((links, name) => `
${links}
<a>.${name}</a>
`, '')
}
`
})
let observer = createObserver(el, {handle,label})
let parentObserver = createObserver(el, {handle,label})
observer.observe(el, { attributes: true })
parentObserver.observe(el.parentNode, { childList:true, subtree:true })
$(label).on('DOMNodeRemoved', _ => {
observer.disconnect()
parentObserver.disconnect()
})
}
const setLabel = (el, label) =>
label.update = el.getBoundingClientRect()
const createLabel = ({el, id, template}) => {
if (!labels[id]) {
const label = document.createElement('visbug-label')
label.text = template
label.position = {
boundingRect: el.getBoundingClientRect(),
node_label_id: id,
}
document.body.appendChild(label)
$(label).on('query', ({detail}) => {
if (!detail.text) return
this.query_text = detail.text
queryPage('[data-pseudo-select]', el =>
el.removeAttribute('data-pseudo-select'))
queryPage(this.query_text + ':not([data-selected])', el =>
detail.activator === 'mouseenter'
? el.setAttribute('data-pseudo-select', true)
: select(el))
})
$(label).on('mouseleave', e => {
e.preventDefault()
e.stopPropagation()
queryPage('[data-pseudo-select]', el =>
el.removeAttribute('data-pseudo-select'))
})
labels[labels.length] = label
return label
}
}
const createHandle = ({el, id}) => {
if (!handles[id]) {
const handle = document.createElement('visbug-handles')
handle.position = { el, node_label_id: id }
document.body.appendChild(handle)
handles[handles.length] = handle
return handle
}
}
const createHover = el => {
if (!el.hasAttribute('data-pseudo-select') && !el.hasAttribute('data-label-id')) {
if (hover_state.element)
hover_state.element.remove()
hover_state.element = document.createElement('visbug-hover')
document.body.appendChild(hover_state.element)
hover_state.element.position = {el}
return hover_state.element
}
}
const createHoverLabel = (el, text) => {
if (!el.hasAttribute('data-pseudo-select') && !el.hasAttribute('data-label-id')) {
if (hover_state.label)
hover_state.label.remove()
hover_state.label = document.createElement('visbug-label')
document.body.appendChild(hover_state.label)
hover_state.label.text = text
hover_state.label.position = {
boundingRect: el.getBoundingClientRect(),
node_label_id: 'hover',
}
hover_state.label.style.setProperty(`--label-bg`, `hsl(267, 100%, 58%)`)
return hover_state.label
}
}
const createCorners = el => {
if (!el.hasAttribute('data-pseudo-select') && !el.hasAttribute('data-label-id')) {
if (hover_state.element)
hover_state.element.remove()
hover_state.element = document.createElement('visbug-corners')
document.body.appendChild(hover_state.element)
hover_state.element.position = {el}
return hover_state.element
}
}
const setHandle = (el, handle) => {
handle.position = {
el,
node_label_id: el.getAttribute('data-label-id'),
}
}
const createObserver = (node, {label,handle}) =>
new MutationObserver(list => {
label && setLabel(node, label)
handle && setHandle(node, handle)
})
const onSelectedUpdate = (cb, immediateCallback = true) => {
selectedCallbacks.push(cb)
if (immediateCallback) cb(selected)
}
const removeSelectedCallback = cb =>
selectedCallbacks = selectedCallbacks.filter(callback => callback != cb)
const tellWatchers = () =>
selectedCallbacks.forEach(cb => cb(selected))
const disconnect = () => {
unselect_all()
unlisten()
}
const on_select_children = (e, {key}) => {
const targets = selected
.filter(node => node.children.length)
.reduce((flat, {children}) =>
[...flat, ...Array.from(children)], [])
if (targets.length) {
e.preventDefault()
e.stopPropagation()
unselect_all()
targets.forEach(node => select(node))
}
}
watchImagesForUpload()
listen()
return {
select,
selection,
unselect_all,
onSelectedUpdate,
removeSelectedCallback,
disconnect,
}
}