diff --git a/app/components/index.js b/app/components/index.js index f6aeb776..b6c6d6d1 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -8,6 +8,8 @@ export { Overlay } from './selection/overlay.element' export { BoxModel } from './selection/box-model.element' export { Corners } from './selection/corners.element' export { Grip } from './selection/grip.element' +export { Rotation } from './selection/rotation.element' +export { Delete } from './selection/delete.element' export { Metatip } from './metatip/metatip.element' export { Ally } from './metatip/ally.element' diff --git a/app/components/selection/delete.element.css b/app/components/selection/delete.element.css new file mode 100644 index 00000000..feef8e9f --- /dev/null +++ b/app/components/selection/delete.element.css @@ -0,0 +1,34 @@ +@import "../_variables.css"; + +:host { + position: var(--position, absolute); + top: var(--top, 0); + left: var(--left, 0); + z-index: var(--layer-top); +} + +:host button { + background: var(--neon-pink); + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + padding: 0; + transition: transform 0.2s ease; +} + +:host button:hover { + transform: scale(1.1); +} + +:host svg { + width: 16px; + height: 16px; + fill: currentColor; +} diff --git a/app/components/selection/delete.element.js b/app/components/selection/delete.element.js new file mode 100644 index 00000000..e6b9c148 --- /dev/null +++ b/app/components/selection/delete.element.js @@ -0,0 +1,82 @@ +import { DeleteStyles } from '../styles.store' +import { animateViewTransition } from '../../utilities/' + +export class Delete extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [DeleteStyles] + this.observers = [] + } + + addObservers(observers) { + this.observers = observers + } + + set linkedElementsToDeleteToo(elements) { + this._linkedElementsToDeleteToo = elements + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = this.styles + this.$shadow.innerHTML = this.render() + + const deleteBtn = this.$shadow.querySelector('button') + deleteBtn.addEventListener('click', this.deleteElement.bind(this)) + } + + set position({el}) { + this.targetElement = el + const {top, right} = el.getBoundingClientRect() + const isFixed = getComputedStyle(el).position === 'fixed' + + this.style.setProperty('--top', `${top + (isFixed ? 0 : window.scrollY)}px`) + this.style.setProperty('--left', `${right + 10}px`) + this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') + } + + async deleteElement(e) { + e.preventDefault() + e.stopPropagation() + + if (!this.targetElement) { + console.warn('No target element found to delete') + return + } + + this.observers.forEach(observer => observer.disconnect()) + + const elements = [ + this.targetElement, + ...this._linkedElementsToDeleteToo || [], + ...Array.from(document.querySelectorAll(`[data-label-id="${this.targetElement.getAttribute('data-label-id')}"]`)), + this + ] + + await animateViewTransition(elements, () => this.performDeletion()) + } + + performDeletion() { + this._linkedElementsToDeleteToo?.forEach(el => el.remove()) + this.targetElement.remove() + + const labelId = this.targetElement.getAttribute('data-label-id') + if (labelId) { + document.querySelectorAll(`[data-label-id="${labelId}"]`).forEach(el => el.remove()) + } + + this.remove() + } + + render() { + return ` + + ` + } +} + +customElements.define('visbug-delete', Delete) diff --git a/app/components/selection/rotation.element.css b/app/components/selection/rotation.element.css new file mode 100644 index 00000000..949af9dd --- /dev/null +++ b/app/components/selection/rotation.element.css @@ -0,0 +1,56 @@ +@import "../_variables.css"; + +:host { + position: var(--position); + top: var(--top); + left: var(--left); + pointer-events: none; + z-index: var(--layer-3); +} + +:host .rotation-line { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +:host .rotation-line.active { + display: block; +} + +:host .rotation-line line { + stroke: var(--neon-pink); + stroke-width: 1; +} + +:host .rotation-handle { + position: absolute; + width: 24px; + height: 24px; + top: -30px; + left: calc(var(--width) / 2 - 12px); + cursor: grab; + pointer-events: all; + background: var(--neon-pink); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + user-select: none; + -webkit-user-select: none; +} + +:host .rotation-handle:active { + cursor: grabbing; +} + +:host .rotation-icon { + width: 16px; + height: 16px; + fill: white; + pointer-events: none; +} diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js new file mode 100644 index 00000000..c32e8cb7 --- /dev/null +++ b/app/components/selection/rotation.element.js @@ -0,0 +1,132 @@ +import { HandlesStyles, RotationStyles } from '../styles.store' + +export class Rotation extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [HandlesStyles, RotationStyles] + this.totalAngle = 0 + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = this.styles + } + + set position({el}) { + this.targetElement = el + + const computedStyle = getComputedStyle(el) + if (computedStyle.display === 'inline') el.style.display = 'inline-block' + + const {left, top, width, height} = el.getBoundingClientRect() + const isFixed = computedStyle.position === 'fixed' + + this.style.setProperty('--top', `${top + (isFixed ? 0 : window.scrollY)}px`) + this.style.setProperty('--left', `${left}px`) + this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') + this.style.setProperty('--width', `${width}px`) + + this.$shadow.innerHTML = this.render() + this.setupRotationHandlers() + } + + setupRotationHandlers() { + const handle = this.$shadow.querySelector('.rotation-handle') + const line = this.$shadow.querySelector('.rotation-line') + + const onMouseDown = e => { + e.preventDefault() + const {left, top, width, height} = this.targetElement.getBoundingClientRect() + this.originalCenter = { + x: left + width / 2, + y: top + height / 2 + } + this.lastAngle = Math.atan2( + e.clientY - this.originalCenter.y, + e.clientX - this.originalCenter.x + ) + + this.handleRadius = Math.sqrt( + Math.pow(e.clientX - this.originalCenter.x, 2) + + Math.pow(e.clientY - this.originalCenter.y, 2) + ) + + line.classList.add('active') + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + const onMouseMove = e => { + const currentAngle = Math.atan2( + e.clientY - this.originalCenter.y, + e.clientX - this.originalCenter.x + ) + + // track accumulated rotation to allow multiple revolutions + if (!this.lastAngle) this.lastAngle = currentAngle + let delta = currentAngle - this.lastAngle + // normalize the delta to avoid "flipping" at boundary crossing + if (delta > Math.PI) { + delta -= 2 * Math.PI + } else if (delta < -Math.PI) { + delta += 2 * Math.PI + } + + this.totalAngle += delta + this.lastAngle = currentAngle + + const rotationDegrees = this.totalAngle * (180 / Math.PI) + this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` + + const handleX = e.clientX + const handleY = e.clientY + + const hostRect = this.getBoundingClientRect() + const handleRect = handle.getBoundingClientRect() + const handleSize = handleRect.width + + // position handle centered on cursor + handle.style.left = `${handleX - hostRect.left - handleSize/2}px` + handle.style.top = `${handleY - hostRect.top - handleSize/2}px` + + const lineSvg = line.querySelector('line') + lineSvg.setAttribute('x1', this.originalCenter.x) + lineSvg.setAttribute('y1', this.originalCenter.y) + lineSvg.setAttribute('x2', handleX) + lineSvg.setAttribute('y2', handleY) + } + + const onMouseUp = () => { + this.lastAngle = 0 + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + line.classList.remove('active') + } + + handle.addEventListener('mousedown', onMouseDown) + + this.cleanup = () => { + handle.removeEventListener('mousedown', onMouseDown) + } + } + + disconnectedCallback() { + this.cleanup && this.cleanup() + } + + render() { + return ` + + + +
+ + + +
+ ` + } +} + +customElements.define('visbug-rotation', Rotation) diff --git a/app/components/styles.store.js b/app/components/styles.store.js index 89c5f07c..21acd945 100644 --- a/app/components/styles.store.js +++ b/app/components/styles.store.js @@ -14,6 +14,8 @@ import { default as boxmodel_css } from './selection/box-model.element.css' import { default as metatip_css } from './metatip/metatip.element.css' import { default as hotkeymap_css } from './hotkey-map/base.element.css' import { default as grip_css } from './selection/grip.element.css' +import { default as rotation_css } from './selection/rotation.element.css' +import { default as delete_css } from './selection/delete.element.css' import { default as light_css } from './_variables_light.css' import { default as visbug_light_css } from './vis-bug/vis-bug.element_light.css' @@ -44,6 +46,8 @@ export const OverlayStyles = constructStylesheet(overlay_css) export const BoxModelStyles = constructStylesheet(boxmodel_css) export const HotkeymapStyles = constructStylesheet(hotkeymap_css) export const GripStyles = constructStylesheet(grip_css) +export const RotationStyles = constructStylesheet(rotation_css) +export const DeleteStyles = constructStylesheet(delete_css) export const LightTheme = constructStylesheet(light_css) export const VisBugLightStyles = constructStylesheet(visbug_light_css) diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 82a82cab..bee3ba16 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -3,7 +3,7 @@ import hotkeys from 'hotkeys-js' import { Handles, Handle, Label, Overlay, Gridlines, Corners, - Hotkeys, Metatip, Ally, Distance, BoxModel, Grip + Hotkeys, Metatip, Ally, Distance, BoxModel, Grip, Rotation, Delete } from '../' import { diff --git a/app/features/selectable.js b/app/features/selectable.js index 71d48879..9af5192f 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -16,7 +16,7 @@ import { metaKey, htmlStringToDom, createClassname, camelToDash, isOffBounds, getStyle, getStyles, deepElementFromPoint, getShadowValues, isSelectorValid, findNearestChildElement, findNearestParentElement, - getTextShadowValues, isFixed, onRemove + getTextShadowValues, isFixed, onRemove, animateViewTransition } from '../utilities/' export function Selectable(visbug) { @@ -25,6 +25,8 @@ export function Selectable(visbug) { let selectedCallbacks = [] let labels = [] let handles = [] + let rotationBtn = null + let deleteBtn = null const hover_state = { target: null, @@ -157,8 +159,8 @@ export function Selectable(visbug) { e.preventDefault() } - const on_delete = e => - selected.length && delete_all() + const on_delete = async e => + selected.length && await delete_all() const on_clearstyles = e => selected.forEach(el => @@ -427,7 +429,7 @@ export function Selectable(visbug) { if (tool === 'guides') { handles.forEach(handle => { handle.hidePopover && handle.hidePopover() - handle.showPopover && handle.showPopover() + if (handle.isConnected && handle.showPopover) handle.showPopover() }) } } @@ -454,7 +456,7 @@ export function Selectable(visbug) { $('visbug-metatip, visbug-ally').forEach(tip => { tip.hidePopover && tip.hidePopover() - tip.showPopover && tip.showPopover() + if (tip.isConnected && tip.showPopover) tip.showPopover() }) selected.unshift(el) @@ -483,6 +485,8 @@ export function Selectable(visbug) { ...$('visbug-label'), ...$('visbug-hover'), ...$('visbug-distance'), + ...$('visbug-delete'), + ...$('visbug-rotation'), ]).forEach(el => el.remove()) @@ -493,24 +497,43 @@ export function Selectable(visbug) { !silent && tellWatchers() } - const delete_all = () => { + const delete_all = async () => { 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()) + const elements = [ + ...selected, + ...labels, + ...handles, + rotationBtn, + deleteBtn + ].filter(Boolean) - labels = [] - handles = [] - selected = [] + await animateViewTransition(elements, () => performDeletion()) selected_after_delete.forEach(el => select(el)) } + const performDeletion = () => { + Array.from([ + ...selected, + ...labels, + ...handles, + rotationBtn, + deleteBtn + ]).forEach(el => el.remove()) + + labels = [] + handles = [] + selected = [] + rotationBtn = null + deleteBtn = null + } + const expandSelection = ({query, all = false}) => { if (all) { const unselecteds = $(query + ':not([data-selected])') @@ -576,11 +599,24 @@ export function Selectable(visbug) { template: handleLabelText(el, visbug.activeTool) }) + deleteBtn = document.createElement('visbug-delete') + rotationBtn = document.createElement('visbug-rotation') + rotationBtn.position = {el} + rotationBtn.setAttribute('data-label-id', id) + + deleteBtn.position = {el} + deleteBtn.linkedElementsToDeleteToo = [rotationBtn] + 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 }) + if (el) observer.observe(el, { attributes: true }) + if (el.parentNode) parentObserver.observe(el.parentNode, { childList:true, subtree:true }) + + deleteBtn.addObservers([observer, parentObserver]) + + document.body.appendChild(deleteBtn) + document.body.appendChild(rotationBtn) if (label !== null) { onRemove(label, () => { diff --git a/app/utilities/common.js b/app/utilities/common.js index 688f0df4..52bfa2da 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -89,6 +89,8 @@ export const isOffBounds = node => || node.closest('visbug-corners') || node.closest('visbug-grip') || node.closest('visbug-gridlines') + || node.closest('visbug-rotation') + || node.closest('visbug-delete') ) export const isSelectorValid = (qs => ( @@ -129,4 +131,25 @@ export const onRemove = (element, callback) => { obs.observe(parent, { childList: true, }) +} + +export async function animateViewTransition(elements, callback) { + if (document.startViewTransition) { + const transition = document.startViewTransition(() => + callback() + ) + await transition.finished + } else { + const animations = elements.map(el => + el.animate([ + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0, transform: 'scale(0.95)' } + ], { + duration: 200, + easing: 'ease-out' + }) + ) + await Promise.all(animations.map(animation => animation.finished)) + callback() + } } \ No newline at end of file diff --git a/app/utilities/strings.js b/app/utilities/strings.js index f463ba1a..e1a932c6 100644 --- a/app/utilities/strings.js +++ b/app/utilities/strings.js @@ -44,4 +44,4 @@ export const altKey = window.navigator.platform.includes('Mac') ? 'opt' : 'alt' -export const notList = ':not(vis-bug):not(script):not(hotkey-map):not(.visbug-metatip):not(visbug-label):not(visbug-handles):not(visbug-corners):not(visbug-grip):not(visbug-gridlines)' +export const notList = ':not(vis-bug):not(script):not(hotkey-map):not(.visbug-metatip):not(visbug-label):not(visbug-handles):not(visbug-corners):not(visbug-grip):not(visbug-gridlines):not(visbug-rotation):not(visbug-delete)'