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 ` + +