From bb651794d033e6a773691f6ec2845f2056038140 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 19:53:01 -0600 Subject: [PATCH 01/17] setup rotatable Rotation (to fix #613 - try number 2 after PR #657) --- app/components/index.js | 1 + app/components/selection/rotation.element.css | 33 +++++++ app/components/selection/rotation.element.js | 95 +++++++++++++++++++ app/components/styles.store.js | 2 + app/components/vis-bug/vis-bug.element.js | 2 +- app/features/position.js | 17 +++- app/utilities/common.js | 1 + app/utilities/strings.js | 2 +- 8 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 app/components/selection/rotation.element.css create mode 100644 app/components/selection/rotation.element.js diff --git a/app/components/index.js b/app/components/index.js index f6aeb776..e3cf52c0 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -8,6 +8,7 @@ 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 { Metatip } from './metatip/metatip.element' export { Ally } from './metatip/ally.element' diff --git a/app/components/selection/rotation.element.css b/app/components/selection/rotation.element.css new file mode 100644 index 00000000..e03fe2b6 --- /dev/null +++ b/app/components/selection/rotation.element.css @@ -0,0 +1,33 @@ +@import "../_variables.css"; + +:host { + position: var(--position); + top: var(--top); + left: var(--left); + pointer-events: none; +} + +:host .rotation-handle { + position: absolute; + width: 24px; + height: 24px; + top: -30px; + left: calc(var(--width) / 2 - 12px); + cursor: grab; + pointer-events: all; + background: var(--theme-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +:host .rotation-handle:active { + cursor: grabbing; +} + +:host .rotation-icon { + width: 16px; + height: 16px; + fill: white; +} diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js new file mode 100644 index 00000000..98917e9d --- /dev/null +++ b/app/components/selection/rotation.element.js @@ -0,0 +1,95 @@ +import { HandlesStyles, RotationStyles } from '../styles.store' + +export class Rotation extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [HandlesStyles, RotationStyles] + this.startAngle = 0 + this.currentAngle = 0 + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = this.styles + } + + set position({el}) { + this.targetElement = el + const {left, top, width, height} = el.getBoundingClientRect() + const isFixed = getComputedStyle(el).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 onMouseDown = e => { + e.preventDefault() + const {left, top, width, height} = this.targetElement.getBoundingClientRect() + const center = { + x: left + width / 2, + y: top + height / 2 + } + this.startAngle = Math.atan2( + e.clientY - center.y, + e.clientX - center.x + ) + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + const onMouseMove = e => { + const {left, top, width, height} = this.targetElement.getBoundingClientRect() + const center = { + x: left + width / 2, + y: top + height / 2 + } + + const angle = Math.atan2( + e.clientY - center.y, + e.clientX - center.x + ) + + const rotation = angle - this.startAngle + this.currentAngle += rotation + this.startAngle = angle + + this.targetElement.style.transform = `rotate(${this.currentAngle * (180 / Math.PI)}deg)` + } + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + 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..a9539be4 100644 --- a/app/components/styles.store.js +++ b/app/components/styles.store.js @@ -14,6 +14,7 @@ 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 light_css } from './_variables_light.css' import { default as visbug_light_css } from './vis-bug/vis-bug.element_light.css' @@ -44,6 +45,7 @@ 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 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..5b08bce0 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 } from '../' import { diff --git a/app/features/position.js b/app/features/position.js index 25a70993..29f58d36 100644 --- a/app/features/position.js +++ b/app/features/position.js @@ -27,8 +27,11 @@ export function Position() { state.elements.forEach(el => el.teardown()) - state.elements = els.map(el => - draggable({el})) + state.elements = els.map(el => { + draggable({el}) + rotatable({el}) + return el + }) } const disconnect = () => { @@ -162,6 +165,16 @@ export function draggable({el, surface = el, cursor = 'move', clickEvent}) { return el } +export function rotatable({el}) { + const rotation = document.createElement('visbug-rotation') + document.body.appendChild(rotation) + rotation.position = {el} + + el.teardown = () => rotation.remove() + + return el +} + export function positionElement(els, direction) { els .map(el => ensurePositionable(el)) diff --git a/app/utilities/common.js b/app/utilities/common.js index 688f0df4..44b1a281 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -89,6 +89,7 @@ export const isOffBounds = node => || node.closest('visbug-corners') || node.closest('visbug-grip') || node.closest('visbug-gridlines') + || node.closest('visbug-rotation') ) export const isSelectorValid = (qs => ( diff --git a/app/utilities/strings.js b/app/utilities/strings.js index f463ba1a..3c4cc163 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)' From 5a52cca4aac6fe78583ea325bf0f9cfe13ecc9f2 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 20:50:24 -0600 Subject: [PATCH 02/17] create a visbug-delete element --- app/components/selection/delete.element.css | 34 +++++++++++ app/components/selection/delete.element.js | 65 +++++++++++++++++++++ app/components/styles.store.js | 2 + app/features/selectable.js | 14 ++++- app/utilities/common.js | 1 + app/utilities/strings.js | 2 +- 6 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 app/components/selection/delete.element.css create mode 100644 app/components/selection/delete.element.js diff --git a/app/components/selection/delete.element.css b/app/components/selection/delete.element.css new file mode 100644 index 00000000..df70d9b7 --- /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(--theme-color); + 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; +} \ No newline at end of file diff --git a/app/components/selection/delete.element.js b/app/components/selection/delete.element.js new file mode 100644 index 00000000..3f43f5be --- /dev/null +++ b/app/components/selection/delete.element.js @@ -0,0 +1,65 @@ +import { DeleteStyles } from '../styles.store' + +export class Delete extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [DeleteStyles] + this.observers = [] + } + + addObservers(observers) { + this.observers = observers + } + + 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') + } + + deleteElement(e) { + e.preventDefault() + e.stopPropagation() + + if (!this.targetElement) { + console.warn('No target element found to delete') + return + } + + this.observers.forEach(observer => observer.disconnect()) + + 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/styles.store.js b/app/components/styles.store.js index a9539be4..21acd945 100644 --- a/app/components/styles.store.js +++ b/app/components/styles.store.js @@ -15,6 +15,7 @@ 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' @@ -46,6 +47,7 @@ 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/features/selectable.js b/app/features/selectable.js index 71d48879..7961d51b 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -19,6 +19,8 @@ import { getTextShadowValues, isFixed, onRemove } from '../utilities/' +import '../components/selection/delete.element.js' + export function Selectable(visbug) { const page = document.body let selected = [] @@ -483,6 +485,7 @@ export function Selectable(visbug) { ...$('visbug-label'), ...$('visbug-hover'), ...$('visbug-distance'), + ...$('visbug-delete'), ]).forEach(el => el.remove()) @@ -576,11 +579,18 @@ export function Selectable(visbug) { template: handleLabelText(el, visbug.activeTool) }) + const deleteBtn = document.createElement('visbug-delete') + deleteBtn.position = {el} + 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) if (label !== null) { onRemove(label, () => { diff --git a/app/utilities/common.js b/app/utilities/common.js index 44b1a281..ac0790ce 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -90,6 +90,7 @@ export const isOffBounds = node => || node.closest('visbug-grip') || node.closest('visbug-gridlines') || node.closest('visbug-rotation') + || node.closest('visbug-delete') ) export const isSelectorValid = (qs => ( diff --git a/app/utilities/strings.js b/app/utilities/strings.js index 3c4cc163..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):not(visbug-rotation)' +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)' From 30f5f6aa55b8b8215d4c51c6bb92c83a5549f4db Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 20:53:27 -0600 Subject: [PATCH 03/17] tweak styling of rotation and delete elements --- app/components/selection/delete.element.css | 4 ++-- app/components/selection/rotation.element.css | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/selection/delete.element.css b/app/components/selection/delete.element.css index df70d9b7..feef8e9f 100644 --- a/app/components/selection/delete.element.css +++ b/app/components/selection/delete.element.css @@ -8,7 +8,7 @@ } :host button { - background: var(--theme-color); + background: var(--neon-pink); border: none; border-radius: 50%; width: 24px; @@ -31,4 +31,4 @@ width: 16px; height: 16px; fill: currentColor; -} \ No newline at end of file +} diff --git a/app/components/selection/rotation.element.css b/app/components/selection/rotation.element.css index e03fe2b6..875fe90c 100644 --- a/app/components/selection/rotation.element.css +++ b/app/components/selection/rotation.element.css @@ -5,6 +5,7 @@ top: var(--top); left: var(--left); pointer-events: none; + z-index: var(--layer-3); } :host .rotation-handle { @@ -15,11 +16,13 @@ left: calc(var(--width) / 2 - 12px); cursor: grab; pointer-events: all; - background: var(--theme-color); + 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 { @@ -30,4 +33,5 @@ width: 16px; height: 16px; fill: white; + pointer-events: none; } From 8c175588a00e4de1a7b822be606ea8f44af7df4c Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 20:55:55 -0600 Subject: [PATCH 04/17] rotate rotation icon with rotation degrees --- app/components/selection/rotation.element.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 98917e9d..0f77d5da 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -62,7 +62,11 @@ export class Rotation extends HTMLElement { this.currentAngle += rotation this.startAngle = angle - this.targetElement.style.transform = `rotate(${this.currentAngle * (180 / Math.PI)}deg)` + const rotationDegrees = this.currentAngle * (180 / Math.PI) + this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` + + const handle = this.$shadow.querySelector('.rotation-handle') + handle.style.transform = `rotate(${rotationDegrees}deg)` } const onMouseUp = () => { From 50bfb7d9fce2bfe1c939d7f7809ff66af354dc2f Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 21:22:56 -0600 Subject: [PATCH 05/17] fix the posiitioning of the handle during rotation --- app/components/selection/rotation.element.js | 35 +++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 0f77d5da..fab1b91e 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -18,6 +18,10 @@ export class Rotation extends HTMLElement { const {left, top, width, height} = el.getBoundingClientRect() const isFixed = getComputedStyle(el).position === 'fixed' + if (!this.handleRadius) { + this.handleRadius = height / 2 + 30 + } + this.style.setProperty('--top', `${top + (isFixed ? 0 : window.scrollY)}px`) this.style.setProperty('--left', `${left}px`) this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') @@ -33,13 +37,13 @@ export class Rotation extends HTMLElement { const onMouseDown = e => { e.preventDefault() const {left, top, width, height} = this.targetElement.getBoundingClientRect() - const center = { + this.originalCenter = { x: left + width / 2, y: top + height / 2 } this.startAngle = Math.atan2( - e.clientY - center.y, - e.clientX - center.x + e.clientY - this.originalCenter.y, + e.clientX - this.originalCenter.x ) document.addEventListener('mousemove', onMouseMove) @@ -47,26 +51,25 @@ export class Rotation extends HTMLElement { } const onMouseMove = e => { - const {left, top, width, height} = this.targetElement.getBoundingClientRect() - const center = { - x: left + width / 2, - y: top + height / 2 - } - - const angle = Math.atan2( - e.clientY - center.y, - e.clientX - center.x + const currentAngle = Math.atan2( + e.clientY - this.originalCenter.y, + e.clientX - this.originalCenter.x ) - const rotation = angle - this.startAngle - this.currentAngle += rotation - this.startAngle = angle + const rotation = currentAngle - this.startAngle + this.currentAngle = rotation const rotationDegrees = this.currentAngle * (180 / Math.PI) this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` const handle = this.$shadow.querySelector('.rotation-handle') - handle.style.transform = `rotate(${rotationDegrees}deg)` + + const handleX = this.originalCenter.x + this.handleRadius * Math.cos(currentAngle) + const handleY = this.originalCenter.y + this.handleRadius * Math.sin(currentAngle) + + const hostRect = this.getBoundingClientRect() + handle.style.left = `${handleX - hostRect.left - 12}px` + handle.style.top = `${handleY - hostRect.top}px` } const onMouseUp = () => { From ba7a377909a20bf2cef90d58c6421804b5b196e6 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 21:43:10 -0600 Subject: [PATCH 06/17] selectable has both delete and rotation, and delete deletes rotation handle too --- app/components/selection/delete.element.js | 6 +++++- app/features/position.js | 1 - app/features/selectable.js | 9 ++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/components/selection/delete.element.js b/app/components/selection/delete.element.js index 3f43f5be..a3dedd56 100644 --- a/app/components/selection/delete.element.js +++ b/app/components/selection/delete.element.js @@ -12,6 +12,10 @@ export class Delete extends HTMLElement { this.observers = observers } + set linkedElementsToDeleteToo(elements) { + this._linkedElementsToDeleteToo = elements + } + connectedCallback() { this.$shadow.adoptedStyleSheets = this.styles this.$shadow.innerHTML = this.render() @@ -40,7 +44,7 @@ export class Delete extends HTMLElement { } this.observers.forEach(observer => observer.disconnect()) - + this._linkedElementsToDeleteToo?.forEach(el => el.remove()) this.targetElement.remove() const labelId = this.targetElement.getAttribute('data-label-id') diff --git a/app/features/position.js b/app/features/position.js index 29f58d36..4aef7e5a 100644 --- a/app/features/position.js +++ b/app/features/position.js @@ -29,7 +29,6 @@ export function Position() { state.elements = els.map(el => { draggable({el}) - rotatable({el}) return el }) } diff --git a/app/features/selectable.js b/app/features/selectable.js index 7961d51b..3b0b1a33 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -486,6 +486,7 @@ export function Selectable(visbug) { ...$('visbug-hover'), ...$('visbug-distance'), ...$('visbug-delete'), + ...$('visbug-rotation'), ]).forEach(el => el.remove()) @@ -580,7 +581,12 @@ export function Selectable(visbug) { }) const deleteBtn = document.createElement('visbug-delete') + const 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}) @@ -589,8 +595,9 @@ export function Selectable(visbug) { 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, () => { From 342952406606a4a5b224789b6a8d5c4216e55072 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:19:17 -0600 Subject: [PATCH 07/17] avoid annoying hover bug when rotating by making rotation handle stay under cursor during rotation --- app/components/selection/rotation.element.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index fab1b91e..1086a463 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -18,10 +18,6 @@ export class Rotation extends HTMLElement { const {left, top, width, height} = el.getBoundingClientRect() const isFixed = getComputedStyle(el).position === 'fixed' - if (!this.handleRadius) { - this.handleRadius = height / 2 + 30 - } - this.style.setProperty('--top', `${top + (isFixed ? 0 : window.scrollY)}px`) this.style.setProperty('--left', `${left}px`) this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') @@ -46,6 +42,11 @@ export class Rotation extends HTMLElement { 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) + ) + document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) } @@ -62,6 +63,11 @@ export class Rotation extends HTMLElement { const rotationDegrees = this.currentAngle * (180 / Math.PI) this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` + this.handleRadius = Math.sqrt( + Math.pow(e.clientX - this.originalCenter.x, 2) + + Math.pow(e.clientY - this.originalCenter.y, 2) + ) + const handle = this.$shadow.querySelector('.rotation-handle') const handleX = this.originalCenter.x + this.handleRadius * Math.cos(currentAngle) From cc565d9f114817be24d81119c2e5ab6126dc6d1d Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:19:58 -0600 Subject: [PATCH 08/17] avoid Uncaught DOMException: HTMLElement.showPopover: Element is not connected when i delete --- app/features/selectable.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/selectable.js b/app/features/selectable.js index 3b0b1a33..4ba75a09 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -429,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() }) } } @@ -456,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) From 3080ee7a6a2f1ca15c9318a182a68c59e7b3a07e Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:22:14 -0600 Subject: [PATCH 09/17] rotation handle stays centered on cursor --- app/components/selection/rotation.element.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 1086a463..d75a0f07 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -69,13 +69,15 @@ export class Rotation extends HTMLElement { ) const handle = this.$shadow.querySelector('.rotation-handle') + const handleRect = handle.getBoundingClientRect() + const handleSize = handleRect.width const handleX = this.originalCenter.x + this.handleRadius * Math.cos(currentAngle) const handleY = this.originalCenter.y + this.handleRadius * Math.sin(currentAngle) const hostRect = this.getBoundingClientRect() - handle.style.left = `${handleX - hostRect.left - 12}px` - handle.style.top = `${handleY - hostRect.top}px` + handle.style.left = `${handleX - hostRect.left - handleSize/2}px` + handle.style.top = `${handleY - hostRect.top - handleSize/2}px` } const onMouseUp = () => { From 217063cff819d0c174fd4ca18b0a69b22d88ce98 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:31:42 -0600 Subject: [PATCH 10/17] show a line to the rotation handle during rotation --- app/components/selection/rotation.element.css | 19 +++++++++++++++++++ app/components/selection/rotation.element.js | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/components/selection/rotation.element.css b/app/components/selection/rotation.element.css index 875fe90c..949af9dd 100644 --- a/app/components/selection/rotation.element.css +++ b/app/components/selection/rotation.element.css @@ -8,6 +8,25 @@ 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; diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index d75a0f07..5e1bb856 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -29,6 +29,7 @@ export class Rotation extends HTMLElement { setupRotationHandlers() { const handle = this.$shadow.querySelector('.rotation-handle') + const line = this.$shadow.querySelector('.rotation-line') const onMouseDown = e => { e.preventDefault() @@ -47,6 +48,8 @@ export class Rotation extends HTMLElement { Math.pow(e.clientY - this.originalCenter.y, 2) ) + line.classList.add('active') + document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) } @@ -78,11 +81,18 @@ export class Rotation extends HTMLElement { const hostRect = this.getBoundingClientRect() 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 = () => { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) + line.classList.remove('active') } handle.addEventListener('mousedown', onMouseDown) @@ -98,6 +108,9 @@ export class Rotation extends HTMLElement { render() { return ` + + +
From 4e5319170ca62ebaefff252b77bc62ccca4deb02 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:49:35 -0600 Subject: [PATCH 11/17] fixed rotatiion "flip" at certain angles --- app/components/selection/rotation.element.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 5e1bb856..302b82de 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -38,10 +38,11 @@ export class Rotation extends HTMLElement { x: left + width / 2, y: top + height / 2 } - this.startAngle = Math.atan2( + this.lastAngle = Math.atan2( e.clientY - this.originalCenter.y, e.clientX - this.originalCenter.x ) + this.currentAngle = 0 this.handleRadius = Math.sqrt( Math.pow(e.clientX - this.originalCenter.x, 2) + @@ -60,8 +61,17 @@ export class Rotation extends HTMLElement { e.clientX - this.originalCenter.x ) - const rotation = currentAngle - this.startAngle - this.currentAngle = rotation + // 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.currentAngle += delta + this.lastAngle = currentAngle const rotationDegrees = this.currentAngle * (180 / Math.PI) this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` From 256dc76330970fdb6cc4acdf3a57d35ddfdca6ae Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 22:54:07 -0600 Subject: [PATCH 12/17] you can continue rotating from where you last let go --- app/components/selection/rotation.element.js | 23 ++++++++------------ 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 302b82de..32ecb6d3 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -5,8 +5,7 @@ export class Rotation extends HTMLElement { super() this.$shadow = this.attachShadow({mode: 'closed'}) this.styles = [HandlesStyles, RotationStyles] - this.startAngle = 0 - this.currentAngle = 0 + this.totalAngle = 0 } connectedCallback() { @@ -42,7 +41,6 @@ export class Rotation extends HTMLElement { e.clientY - this.originalCenter.y, e.clientX - this.originalCenter.x ) - this.currentAngle = 0 this.handleRadius = Math.sqrt( Math.pow(e.clientX - this.originalCenter.x, 2) + @@ -70,25 +68,21 @@ export class Rotation extends HTMLElement { } else if (delta < -Math.PI) { delta += 2 * Math.PI } - this.currentAngle += delta + + this.totalAngle += delta this.lastAngle = currentAngle - const rotationDegrees = this.currentAngle * (180 / Math.PI) + const rotationDegrees = this.totalAngle * (180 / Math.PI) this.targetElement.style.transform = `rotate(${rotationDegrees}deg)` - this.handleRadius = Math.sqrt( - Math.pow(e.clientX - this.originalCenter.x, 2) + - Math.pow(e.clientY - this.originalCenter.y, 2) - ) + const handleX = e.clientX + const handleY = e.clientY - const handle = this.$shadow.querySelector('.rotation-handle') + const hostRect = this.getBoundingClientRect() const handleRect = handle.getBoundingClientRect() const handleSize = handleRect.width - const handleX = this.originalCenter.x + this.handleRadius * Math.cos(currentAngle) - const handleY = this.originalCenter.y + this.handleRadius * Math.sin(currentAngle) - - const hostRect = this.getBoundingClientRect() + // position handle centered on cursor handle.style.left = `${handleX - hostRect.left - handleSize/2}px` handle.style.top = `${handleY - hostRect.top - handleSize/2}px` @@ -100,6 +94,7 @@ export class Rotation extends HTMLElement { } const onMouseUp = () => { + this.lastAngle = 0 document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) line.classList.remove('active') From 4bd3fa79a229f30b4c7ff3f32e78e042d626c906 Mon Sep 17 00:00:00 2001 From: hchiam Date: Tue, 18 Mar 2025 23:14:45 -0600 Subject: [PATCH 13/17] revert/cleanup --- app/features/position.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/features/position.js b/app/features/position.js index 4aef7e5a..25a70993 100644 --- a/app/features/position.js +++ b/app/features/position.js @@ -27,10 +27,8 @@ export function Position() { state.elements.forEach(el => el.teardown()) - state.elements = els.map(el => { - draggable({el}) - return el - }) + state.elements = els.map(el => + draggable({el})) } const disconnect = () => { @@ -164,16 +162,6 @@ export function draggable({el, surface = el, cursor = 'move', clickEvent}) { return el } -export function rotatable({el}) { - const rotation = document.createElement('visbug-rotation') - document.body.appendChild(rotation) - rotation.position = {el} - - el.teardown = () => rotation.remove() - - return el -} - export function positionElement(els, direction) { els .map(el => ensurePositionable(el)) From 5e51cdf7b6d16a55632f381a96cd3d3783e968bc Mon Sep 17 00:00:00 2001 From: hchiam Date: Mon, 21 Apr 2025 17:42:58 -0600 Subject: [PATCH 14/17] import Delete more consistently --- app/components/index.js | 1 + app/components/vis-bug/vis-bug.element.js | 2 +- app/features/selectable.js | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/components/index.js b/app/components/index.js index e3cf52c0..b6c6d6d1 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -9,6 +9,7 @@ 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/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 5b08bce0..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, Rotation + Hotkeys, Metatip, Ally, Distance, BoxModel, Grip, Rotation, Delete } from '../' import { diff --git a/app/features/selectable.js b/app/features/selectable.js index 4ba75a09..cca9ad98 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -19,8 +19,6 @@ import { getTextShadowValues, isFixed, onRemove } from '../utilities/' -import '../components/selection/delete.element.js' - export function Selectable(visbug) { const page = document.body let selected = [] From 991e1554dc452fa3351f297d162f4adb617a7782 Mon Sep 17 00:00:00 2001 From: hchiam Date: Mon, 21 Apr 2025 17:45:54 -0600 Subject: [PATCH 15/17] fix pressing delete on the keyboard also now deletes associated rotation and delete buttons --- app/features/selectable.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/features/selectable.js b/app/features/selectable.js index cca9ad98..3ffe5a70 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -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, @@ -502,12 +504,19 @@ export function Selectable(visbug) { else if (el.parentNode) return el.parentNode }) - Array.from([...selected, ...labels, ...handles]).forEach(el => - el.remove()) - - labels = [] - handles = [] - selected = [] + Array.from([ + ...selected, + ...labels, + ...handles, + rotationBtn, + deleteBtn + ]).forEach(el => el.remove()) + + labels = [] + handles = [] + selected = [] + rotationBtn = null + deleteBtn = null selected_after_delete.forEach(el => select(el)) @@ -578,8 +587,8 @@ export function Selectable(visbug) { template: handleLabelText(el, visbug.activeTool) }) - const deleteBtn = document.createElement('visbug-delete') - const rotationBtn = document.createElement('visbug-rotation') + deleteBtn = document.createElement('visbug-delete') + rotationBtn = document.createElement('visbug-rotation') rotationBtn.position = {el} rotationBtn.setAttribute('data-label-id', id) From 5a0229b5923cda45c2e2b7a8f0b23839cf0e58e7 Mon Sep 17 00:00:00 2001 From: hchiam Date: Mon, 21 Apr 2025 17:56:17 -0600 Subject: [PATCH 16/17] Rotation changes display inline to inline-block - should be safe since width is also set --- app/components/selection/rotation.element.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/components/selection/rotation.element.js b/app/components/selection/rotation.element.js index 32ecb6d3..c32e8cb7 100644 --- a/app/components/selection/rotation.element.js +++ b/app/components/selection/rotation.element.js @@ -14,8 +14,12 @@ export class Rotation extends HTMLElement { 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 = getComputedStyle(el).position === 'fixed' + const isFixed = computedStyle.position === 'fixed' this.style.setProperty('--top', `${top + (isFixed ? 0 : window.scrollY)}px`) this.style.setProperty('--left', `${left}px`) From 9e97ba658f7664342ea911f106cb8d292efe8d41 Mon Sep 17 00:00:00 2001 From: hchiam Date: Mon, 21 Apr 2025 18:39:31 -0600 Subject: [PATCH 17/17] animateViewTransition (with fallback) --- app/components/selection/delete.element.js | 15 +++++++++++- app/features/selectable.js | 28 +++++++++++++++------- app/utilities/common.js | 21 ++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/app/components/selection/delete.element.js b/app/components/selection/delete.element.js index a3dedd56..e6b9c148 100644 --- a/app/components/selection/delete.element.js +++ b/app/components/selection/delete.element.js @@ -1,4 +1,5 @@ import { DeleteStyles } from '../styles.store' +import { animateViewTransition } from '../../utilities/' export class Delete extends HTMLElement { constructor() { @@ -34,7 +35,7 @@ export class Delete extends HTMLElement { this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') } - deleteElement(e) { + async deleteElement(e) { e.preventDefault() e.stopPropagation() @@ -44,6 +45,18 @@ export class Delete extends HTMLElement { } 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() diff --git a/app/features/selectable.js b/app/features/selectable.js index 3ffe5a70..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) { @@ -159,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 => @@ -497,13 +497,28 @@ 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 }) + const elements = [ + ...selected, + ...labels, + ...handles, + rotationBtn, + deleteBtn + ].filter(Boolean) + + await animateViewTransition(elements, () => performDeletion()) + + selected_after_delete.forEach(el => + select(el)) + } + + const performDeletion = () => { Array.from([ ...selected, ...labels, @@ -511,15 +526,12 @@ export function Selectable(visbug) { rotationBtn, deleteBtn ]).forEach(el => el.remove()) - + labels = [] handles = [] selected = [] rotationBtn = null deleteBtn = null - - selected_after_delete.forEach(el => - select(el)) } const expandSelection = ({query, all = false}) => { diff --git a/app/utilities/common.js b/app/utilities/common.js index ac0790ce..52bfa2da 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -131,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