UNPKG

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
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, } }