From ea289bfda005af41b36c8e9cbf67fde8d880b92c Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 28 Aug 2026 10:35:59 +0200 Subject: [PATCH 1/4] fix: wait for dev bundle before CI tests Build the development bundle before starting the static test server, and probe bundle.js instead of the HTML shell so AVA cannot start while Rollup is still compiling. Also wait for the vis-bug custom element's shadow toolbar to be initialized before browser tests interact with it. This prevents the startup race that caused querySelector failures in the accessibility and box-shadow suites. --- package.json | 2 +- tests/helpers.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 98994388..745ecd04 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test": "ava", "test:dev": "ava -v -w", "test:server": "browser-sync start --server \"app\" --files \"app/index.html,app/bundle.css,app/bundle.js\" --no-open --no-notify --no-ui --no-ghost-mode", - "test:ci": "start-server-and-test http://localhost:3000" + "test:ci": "npm run bundle && start-server-and-test test:server http://localhost:3000/bundle.js test" }, "dependencies": { "@chenglou/pretext": "^0.0.8", diff --git a/tests/helpers.js b/tests/helpers.js index 6f70c20c..29fe357c 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -8,8 +8,11 @@ export const setupPptrTab = async t => { t.context.page = await t.context.browser.newPage() await t.context.page.goto('http://localhost:3000') + await t.context.page.waitForFunction(() => { + const visbug = document.querySelector('vis-bug') + return Boolean(visbug?.$shadow?.querySelector('li[data-tool]')) + }) await t.context.page.evaluateHandle(`document.body.setAttribute('testing', true)`) - await t.context.page.waitForSelector('vis-bug') } export const teardownPptrTab = async ({context:{ page, browser }}) => { From 815ee44510ef44a672935579873482871073c229 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 28 Aug 2026 10:57:08 +0200 Subject: [PATCH 2/4] feat: add projective 3D transform plugin Register a built-in 3D transform selection action and command that replaces the standard selection overlays with four draggable projective corner handles. Compute and serialize perspective matrix3d transforms, preserve existing transforms and transform origins, and record each completed drag as a single undoable style change. Keep transform mode active across pointer release, exit cleanly on outside clicks, and normalize viewport coordinates for scrolled documents. Add unit and browser coverage for matrix generation, overlay switching, undo/redo, click behavior, and scrolling. --- app/components/index.js | 1 + app/components/selection/actions.test.js | 4 +- .../projective-transform.element.css | 54 +++ .../selection/projective-transform.element.js | 326 ++++++++++++++++++ .../selection/projective-transform.js | 122 +++++++ .../selection/projective-transform.test.js | 56 +++ app/components/styles.store.js | 2 + app/plugins/_registry.js | 14 + app/plugins/projective-transform.js | 33 ++ app/plugins/projective-transform.test.js | 130 +++++++ app/utilities/common.js | 1 + app/utilities/strings.js | 2 +- 12 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 app/components/selection/projective-transform.element.css create mode 100644 app/components/selection/projective-transform.element.js create mode 100644 app/components/selection/projective-transform.js create mode 100644 app/components/selection/projective-transform.test.js create mode 100644 app/plugins/projective-transform.js create mode 100644 app/plugins/projective-transform.test.js diff --git a/app/components/index.js b/app/components/index.js index b951c6d6..3734bf47 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -14,6 +14,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 { ProjectiveTransform } from './selection/projective-transform.element' export { SelectionActions } from './selection/actions.element' export { Metatip } from './metatip/metatip.element' diff --git a/app/components/selection/actions.test.js b/app/components/selection/actions.test.js index 75416b82..e19a2fb7 100644 --- a/app/components/selection/actions.test.js +++ b/app/components/selection/actions.test.js @@ -19,7 +19,7 @@ test('Should show all export formats in the selection actions menu', async t => group: shadow.querySelector('.group').firstChild.textContent, rootOpen: shadow.querySelector('.menu').matches(':popover-open'), submenuOpen: shadow.querySelector('.submenu-items').matches(':popover-open'), - formats: Array.from(shadow.querySelectorAll('[data-command]')) + formats: Array.from(shadow.querySelectorAll('.submenu-items [data-command]')) .map(button => button.textContent), } }) @@ -43,10 +43,12 @@ test('Should hide when no contributing plug-in is active', async t => { const hidden = await page.evaluate(() => { const visbug = document.querySelector('vis-bug') visbug.setPluginActive('export', false) + visbug.setPluginActive('projective-transform', false) const handles = document.querySelector('visbug-handles') const result = handles.$shadow .querySelector('visbug-selection-actions').hidden visbug.setPluginActive('export', true) + visbug.setPluginActive('projective-transform', true) return result }) diff --git a/app/components/selection/projective-transform.element.css b/app/components/selection/projective-transform.element.css new file mode 100644 index 00000000..60e5083b --- /dev/null +++ b/app/components/selection/projective-transform.element.css @@ -0,0 +1,54 @@ +@import "../_variables.css"; + +:host { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + box-sizing: border-box; + margin: 0; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + pointer-events: none; + z-index: var(--layer-3); +} + +:host::backdrop { + background: none !important; +} + +svg { + display: block; + width: 100%; + height: 100%; + overflow: hidden; +} + +.outline { + fill: none; + stroke: var(--neon-pink); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.handle { + fill: white; + stroke: var(--neon-pink); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: auto; + cursor: move; +} + +.cross { + stroke: var(--neon-pink); + stroke-width: 1.5; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + pointer-events: none; +} diff --git a/app/components/selection/projective-transform.element.js b/app/components/selection/projective-transform.element.js new file mode 100644 index 00000000..aff6f2c4 --- /dev/null +++ b/app/components/selection/projective-transform.element.js @@ -0,0 +1,326 @@ +import { ProjectiveTransformStyles } from '../styles.store' +import { StyleChange } from '../../features/history' +import { getBoxQuad, quadPath } from './quad' +import { + parseProjectiveTransform, + pointsApproximatelyEqual, + projectiveMatrixValues, + serializeMatrix3d, +} from './projective-transform' + +const HANDLE_RADIUS = 7 +const CROSS_RADIUS = 3.5 + +const quadPoints = quad => [quad.p1, quad.p2, quad.p3, quad.p4] + +const finitePoint = point => + Number.isFinite(point?.x) && Number.isFinite(point?.y) + +export class ProjectiveTransform extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [ProjectiveTransformStyles] + this.position_frame = null + this.pointer_id = undefined + this.on_pointer_down = this.on_pointer_down.bind(this) + this.on_pointer_move = this.on_pointer_move.bind(this) + this.on_pointer_up = this.on_pointer_up.bind(this) + this.on_position_change = this.on_position_change.bind(this) + this.on_document_click = this.on_document_click.bind(this) + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = this.styles + this.$shadow.innerHTML = this.render() + this.svg = this.$shadow.querySelector('svg') + this.path = this.$shadow.querySelector('.outline') + this.svg.addEventListener('pointerdown', this.on_pointer_down) + window.addEventListener('resize', this.on_position_change) + window.addEventListener('scroll', this.on_position_change, true) + document.addEventListener('click', this.on_document_click, true) + + this.source_observer = new MutationObserver(this.on_position_change) + if (this.source_el) this.source_observer.observe(this.source_el, { + attributes: true, + attributeFilter: ['class', 'style', 'data-selected'], + }) + + this.setAttribute('popover', 'manual') + this.showPopover && this.showPopover() + this.update_position() + } + + disconnectedCallback() { + this.hidePopover && this.hidePopover() + this.svg?.removeEventListener('pointerdown', this.on_pointer_down) + window.removeEventListener('resize', this.on_position_change) + window.removeEventListener('scroll', this.on_position_change, true) + document.removeEventListener('click', this.on_document_click, true) + this.source_observer?.disconnect() + this.position_frame && cancelAnimationFrame(this.position_frame) + this.stop_drag() + } + + set source(element) { + this.source_observer?.disconnect() + this.source_el = element + + if (this.isConnected && element) { + this.source_observer.observe(element, { + attributes: true, + attributeFilter: ['class', 'style', 'data-selected'], + }) + this.update_position() + } + } + + get source() { + return this.source_el + } + + on_position_change() { + if (!this.source_el?.isConnected || !this.source_el.hasAttribute('data-selected')) { + this.remove() + return + } + if (this.position_frame || this.pointer_id !== undefined) return + + this.position_frame = requestAnimationFrame(() => { + this.position_frame = null + this.update_position() + }) + } + + on_document_click(event) { + if (!event.composedPath().includes(this)) this.remove() + } + + update_position() { + if (!this.path || !this.source_el?.isConnected) return + + const points = quadPoints(getBoxQuad(this.source_el)) + if (points.some(point => !finitePoint(point))) { + this.remove() + return + } + + this.path.setAttribute('d', quadPath({ + p1: points[0], p2: points[1], p3: points[2], p4: points[3], + })) + + points.forEach((point, index) => { + const group = this.$shadow.querySelector(`[data-corner="${index}"]`) + group.setAttribute('transform', `translate(${point.x} ${point.y})`) + }) + } + + on_pointer_down(event) { + const handle = event.target.closest('.handle') + if (!handle || event.button !== 0 || !this.start_drag()) return + + event.preventDefault() + event.stopPropagation() + this.active_corner = Number(handle.dataset.corner) + this.pointer_id = event.pointerId + this.drag_handle = handle + this.original_cursor = document.body.style.cursor + this.original_user_select = document.body.style.userSelect + document.body.style.cursor = 'move' + document.body.style.userSelect = 'none' + handle.setPointerCapture(event.pointerId) + handle.addEventListener('pointermove', this.on_pointer_move) + handle.addEventListener('pointerup', this.on_pointer_up) + handle.addEventListener('pointercancel', this.on_pointer_up) + } + + start_drag() { + if (!this.source_el?.isConnected) return false + + this.original_transform = this.source_el.style.transform + this.original_priority = this.source_el.style.getPropertyPriority('transform') + this.original_transition = this.source_el.style.transition + const persistedTransform = this.original_transform + || getComputedStyle(this.source_el).transform + const parsed = parseProjectiveTransform(persistedTransform) + this.base_transform = parsed.baseTransform + this.projective_transform = parsed.projectiveTransform + const currentQuad = getBoxQuad(this.source_el) + + this.source_el.style.transition = 'none' + this.source_el.style.transform = this.build_transform(this.base_transform, '') + this.target_points = quadPoints(currentQuad) + .map(point => this.viewport_to_local(point)) + this.source_el.style.transform = this.build_transform( + this.base_transform, + this.projective_transform, + ) + + if (this.target_points.length !== 4 + || this.target_points.some(point => !finitePoint(point))) { + this.source_el.style.transform = this.original_transform + this.source_el.style.transition = this.original_transition + return false + } + + return true + } + + on_pointer_move(event) { + if (event.pointerId !== this.pointer_id) return + + event.preventDefault() + event.stopPropagation() + const point = this.local_pointer_point(event) + if (!point) return + + this.target_points[this.active_corner] = point + this.apply_preview() + } + + on_pointer_up(event) { + if (event.pointerId !== this.pointer_id) return + + event.preventDefault() + event.stopPropagation() + const point = this.local_pointer_point(event) + if (point) { + this.target_points[this.active_corner] = point + this.apply_preview() + } + + const newTransform = this.source_el.style.transform + this.stop_drag() + + if (this.original_transform !== newTransform) { + document.querySelector('vis-bug')?.history?.push(new StyleChange({ + element: this.source_el, + property: 'transform', + oldValue: this.original_transform, + newValue: newTransform, + oldPriority: this.original_priority, + newPriority: this.source_el.style.getPropertyPriority('transform'), + })) + } + + this.update_position() + } + + stop_drag() { + if (this.drag_handle) { + this.drag_handle.removeEventListener('pointermove', this.on_pointer_move) + this.drag_handle.removeEventListener('pointerup', this.on_pointer_up) + this.drag_handle.removeEventListener('pointercancel', this.on_pointer_up) + } + if (this.original_transition !== undefined && this.source_el) + this.source_el.style.transition = this.original_transition + if (this.original_cursor !== undefined) + document.body.style.cursor = this.original_cursor + if (this.original_user_select !== undefined) + document.body.style.userSelect = this.original_user_select + + this.pointer_id = undefined + this.active_corner = undefined + this.drag_handle = null + this.original_transition = undefined + this.original_cursor = undefined + this.original_user_select = undefined + } + + local_pointer_point(event) { + const previewTransform = this.source_el.style.transform + this.source_el.style.transform = this.build_transform(this.base_transform, '') + const point = this.viewport_to_local({x: event.clientX, y: event.clientY}) + this.source_el.style.transform = previewTransform + return finitePoint(point) ? {x: point.x, y: point.y} : null + } + + viewport_to_local(point) { + return this.source_el.convertPointFromNode({ + x: point.x + window.scrollX, + y: point.y + window.scrollY, + }, document.documentElement) + } + + apply_preview() { + const projectiveTransform = this.build_projective_transform(this.target_points) + if (projectiveTransform == null) return + + this.projective_transform = projectiveTransform + this.source_el.style.transform = this.build_transform( + this.base_transform, + projectiveTransform, + ) + this.update_position() + } + + build_projective_transform(points) { + const {width, height} = this.element_size() + const sourcePoints = [ + {x: 0, y: 0}, + {x: width, y: 0}, + {x: width, y: height}, + {x: 0, y: height}, + ] + + if (!width || !height || points.length !== 4) return '' + if (pointsApproximatelyEqual(points, sourcePoints)) return '' + + const values = projectiveMatrixValues(points, width, height) + if (!values) return null + + const matrix = new DOMMatrix(values) + const [originX, originY] = getComputedStyle(this.source_el) + .transformOrigin.split(' ') + .map(value => parseFloat(value) || 0) + const corrected = new DOMMatrix() + .translate(-originX, -originY) + .multiply(matrix) + .multiply(new DOMMatrix().translate(originX, originY)) + + return serializeMatrix3d(corrected) + } + + element_size() { + const style = getComputedStyle(this.source_el) + return { + width: this.source_el.offsetWidth + || parseFloat(style.width) + || this.source_el.getBBox?.().width + || 0, + height: this.source_el.offsetHeight + || parseFloat(style.height) + || this.source_el.getBBox?.().height + || 0, + } + } + + build_transform(baseTransform, projectiveTransform) { + return [baseTransform, projectiveTransform] + .map(transform => transform?.trim()) + .filter(Boolean) + .join(' ') + } + + render() { + return ` + + + ${[0, 1, 2, 3].map(index => ` + + + + + `).join('')} + + ` + } +} + +customElements.define('visbug-projective-transform', ProjectiveTransform) diff --git a/app/components/selection/projective-transform.js b/app/components/selection/projective-transform.js new file mode 100644 index 00000000..efb597a5 --- /dev/null +++ b/app/components/selection/projective-transform.js @@ -0,0 +1,122 @@ +const EPSILON = 1e-8 + +export const splitTransformFunctions = transform => { + const parts = [] + let startIndex = -1 + let depth = 0 + + for (let index = 0; index < transform.length; index++) { + const character = transform[index] + + if (character === '(') { + depth++ + } + else if (character === ')') { + depth-- + if (depth === 0 && startIndex !== -1) { + parts.push(transform.slice(startIndex, index + 1).trim()) + startIndex = -1 + } + } + else if (depth === 0 && startIndex === -1 && character.trim()) { + startIndex = index + } + } + + if (!parts.length && transform.trim()) parts.push(transform.trim()) + return parts +} + +export const parseProjectiveTransform = transform => { + if (!transform || transform === 'none') + return {baseTransform: '', projectiveTransform: ''} + + const parts = splitTransformFunctions(transform) + const lastPart = parts.at(-1) || '' + const functionName = lastPart + .slice(0, lastPart.indexOf('(')) + .trim() + .toLowerCase() + + if (functionName === 'matrix' || functionName === 'matrix3d') { + return { + baseTransform: parts.slice(0, -1).join(' ').trim(), + projectiveTransform: lastPart, + } + } + + return {baseTransform: transform.trim(), projectiveTransform: ''} +} + +export const projectiveMatrixValues = (points, width, height) => { + if (!width || !height || points.length !== 4) return null + + const [p1, p2, p3, p4] = points + const dx1 = p2.x - p3.x + const dx2 = p4.x - p3.x + const dx3 = p1.x - p2.x + p3.x - p4.x + const dy1 = p2.y - p3.y + const dy2 = p4.y - p3.y + const dy3 = p1.y - p2.y + p3.y - p4.y + let aUnit + let bUnit + let dUnit + let eUnit + let gUnit + let hUnit + + if (Math.abs(dx3) < EPSILON && Math.abs(dy3) < EPSILON) { + aUnit = p2.x - p1.x + bUnit = p4.x - p1.x + dUnit = p2.y - p1.y + eUnit = p4.y - p1.y + gUnit = 0 + hUnit = 0 + } + else { + const determinant = dx1 * dy2 - dx2 * dy1 + if (Math.abs(determinant) < EPSILON) return null + + gUnit = (dx3 * dy2 - dx2 * dy3) / determinant + hUnit = (dx1 * dy3 - dx3 * dy1) / determinant + aUnit = p2.x - p1.x + gUnit * p2.x + bUnit = p4.x - p1.x + hUnit * p4.x + dUnit = p2.y - p1.y + gUnit * p2.y + eUnit = p4.y - p1.y + hUnit * p4.y + } + + const values = [ + aUnit / width, dUnit / width, 0, gUnit / width, + bUnit / height, eUnit / height, 0, hUnit / height, + 0, 0, 1, 0, + p1.x, p1.y, 0, 1, + ] + + return values.every(Number.isFinite) ? values : null +} + +export const formatMatrixNumber = value => { + if (Math.abs(value) < 1e-10) return '0' + + const rounded = value.toFixed(8).replace(/0+$/, '').replace(/\.$/, '') + return rounded === '-0' ? '0' : rounded +} + +export const serializeMatrix3d = matrix => { + const values = [ + matrix.m11, matrix.m12, matrix.m13, matrix.m14, + matrix.m21, matrix.m22, matrix.m23, matrix.m24, + matrix.m31, matrix.m32, matrix.m33, matrix.m34, + matrix.m41, matrix.m42, matrix.m43, matrix.m44, + ] + + return `matrix3d(${values.map(formatMatrixNumber).join(', ')})` +} + +export const pointsApproximatelyEqual = (pointsA, pointsB) => + pointsA.length === pointsB.length + && pointsA.every((point, index) => { + const compareTo = pointsB[index] + return Math.abs(point.x - compareTo.x) < .01 + && Math.abs(point.y - compareTo.y) < .01 + }) diff --git a/app/components/selection/projective-transform.test.js b/app/components/selection/projective-transform.test.js new file mode 100644 index 00000000..2adcae04 --- /dev/null +++ b/app/components/selection/projective-transform.test.js @@ -0,0 +1,56 @@ +import test from 'ava' +import { + parseProjectiveTransform, + projectiveMatrixValues, + splitTransformFunctions, +} from './projective-transform.js' + +test('splits and identifies a trailing projective matrix', t => { + const transform = 'translate(2px, 4px) rotate(10deg) matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)' + + t.deepEqual(splitTransformFunctions(transform), [ + 'translate(2px, 4px)', + 'rotate(10deg)', + 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)', + ]) + t.deepEqual(parseProjectiveTransform(transform), { + baseTransform: 'translate(2px, 4px) rotate(10deg)', + projectiveTransform: 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)', + }) +}) + +test('preserves non-matrix transforms as the base transform', t => { + t.deepEqual(parseProjectiveTransform('rotate(12deg)'), { + baseTransform: 'rotate(12deg)', + projectiveTransform: '', + }) +}) + +test('creates an identity matrix for an unchanged rectangle', t => { + const values = projectiveMatrixValues([ + {x: 0, y: 0}, + {x: 100, y: 0}, + {x: 100, y: 50}, + {x: 0, y: 50}, + ], 100, 50) + + t.deepEqual(values, [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]) +}) + +test('creates perspective terms when one corner moves', t => { + const values = projectiveMatrixValues([ + {x: 15, y: 10}, + {x: 100, y: 0}, + {x: 100, y: 50}, + {x: 0, y: 50}, + ], 100, 50) + + t.truthy(values) + t.true(values.every(Number.isFinite)) + t.true(Math.abs(values[3]) > 0 || Math.abs(values[7]) > 0) +}) diff --git a/app/components/styles.store.js b/app/components/styles.store.js index 7dc2c693..f6f1fcf8 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 projectiveTransform_css } from './selection/projective-transform.element.css' import { default as actions_css } from './selection/actions.element.css' import { default as light_css } from './_variables_light.css' @@ -47,6 +48,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 ProjectiveTransformStyles = constructStylesheet(projectiveTransform_css) export const ActionsStyles = constructStylesheet(actions_css) export const LightTheme = constructStylesheet(light_css) diff --git a/app/plugins/_registry.js b/app/plugins/_registry.js index 497b6743..99548bee 100644 --- a/app/plugins/_registry.js +++ b/app/plugins/_registry.js @@ -23,6 +23,12 @@ import { selectionActions as export_selection_actions, default as ExportPlugin } from './export' +import { + commands as projective_transform_commands, + description as projective_transform_description, + selectionActions as projective_transform_selection_actions, + default as ProjectiveTransformPlugin +} from './projective-transform' export const PluginRegistry = new Map() export const SelectionActionRegistry = new Map() @@ -143,6 +149,13 @@ const builtInPlugins = [ {id: 'loop-through-widths', commands: loop_thru_widths_commands, execute: LoopThruWidths}, // ...commandsToHash(placeholdifier_commands, PlaceholdifierPlugin), {id: 'expand-text', commands: expand_text_commands, execute: ExpandTextPlugin}, + { + id: 'projective-transform', + commands: projective_transform_commands, + execute: ProjectiveTransformPlugin, + selectionActions: projective_transform_selection_actions, + active: true, + }, { id: 'export', commands: export_commands, @@ -174,6 +187,7 @@ export const PluginHints = [ {command: loop_thru_widths_commands[0], description: loop_thru_widths_description}, // {command: placeholdifier_commands[0], description: placeholdifier_description}, {command: expand_text_commands[0], description: expand_text_description}, + {command: projective_transform_commands[0], description: projective_transform_description}, // ...colorblind_commands.map(cbc => { // return { // command: cbc, description: `simulate ${cbc}` diff --git a/app/plugins/projective-transform.js b/app/plugins/projective-transform.js new file mode 100644 index 00000000..44299e49 --- /dev/null +++ b/app/plugins/projective-transform.js @@ -0,0 +1,33 @@ +export const description = 'projectively transform a selected element by moving its corners' +export const commands = ['3d-transform'] +export const selectionActions = [{ + id: '3d-transform', + label: '3D transform', + command: '3d-transform', + order: 90, +}] + +const selectionOverlays = [ + 'visbug-handles', + 'visbug-label', + 'visbug-hover', + 'visbug-distance', + 'visbug-rotation', + 'visbug-grip', + 'visbug-corners', +].join(',') + +export default function projectiveTransform({selected, source}) { + const element = source || selected[0] + if (!element) throw new Error('Select an element before using 3D transform') + + document.querySelectorAll('visbug-projective-transform') + .forEach(overlay => overlay.remove()) + document.querySelectorAll(selectionOverlays) + .forEach(overlay => overlay.remove()) + + const overlay = document.createElement('visbug-projective-transform') + overlay.source = element + document.body.appendChild(overlay) + return overlay +} diff --git a/app/plugins/projective-transform.test.js b/app/plugins/projective-transform.test.js new file mode 100644 index 00000000..7853923c --- /dev/null +++ b/app/plugins/projective-transform.test.js @@ -0,0 +1,130 @@ +import test from 'ava' +import { + setupPptrTab, teardownPptrTab, pptrMetaKey +} from '../../tests/helpers.js' + +const target = 'h2[style*="text-shadow"]' + +const shortcut = async (page, modifier, {shift = false} = {}) => { + await page.keyboard.down(modifier) + if (shift) await page.keyboard.down('Shift') + await page.keyboard.press('KeyZ') + if (shift) await page.keyboard.up('Shift') + await page.keyboard.up(modifier) +} + +test.beforeEach(setupPptrTab) +test.afterEach.always(teardownPptrTab) + +test('shows four projective handles and supports undo and redo', async t => { + const {page} = t.context + const modifier = await pptrMetaKey(page) + + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + const overlays = await page.evaluate(() => ({ + projective: document.querySelectorAll('visbug-projective-transform').length, + regular: document.querySelectorAll( + 'visbug-handles, visbug-label, visbug-rotation, visbug-hover' + ).length, + handles: document.querySelector('visbug-projective-transform') + .$shadow.querySelectorAll('.handle').length, + crosses: document.querySelector('visbug-projective-transform') + .$shadow.querySelectorAll('.cross').length, + })) + + t.deepEqual(overlays, { + projective: 1, + regular: 0, + handles: 4, + crosses: 8, + }) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 35, handle.y + 20, {steps: 4}) + await page.mouse.up() + + const transformed = await page.$eval(target, element => element.style.transform) + t.regex(transformed, /matrix3d\(/) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) + + await shortcut(page, modifier) + t.is(await page.$eval(target, element => element.style.transform), '') + + await shortcut(page, modifier, {shift: true}) + t.is(await page.$eval(target, element => element.style.transform), transformed) + + await page.click('article:nth-of-type(2)') + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) + t.is(await page.$$('visbug-handles').then(items => items.length), 1) +}) + +test('selection action replaces the regular selection overlays', async t => { + const {page} = t.context + await page.click(target) + + const action = await page.evaluate(async () => { + const menu = document.querySelector('visbug-handles').$shadow + .querySelector('visbug-selection-actions') + const shadow = menu.$shadow + shadow.querySelector('.trigger').click() + await new Promise(resolve => requestAnimationFrame(resolve)) + const button = shadow.querySelector('[data-command="3d-transform"]') + const bounds = button.getBoundingClientRect() + return {x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2} + }) + + await page.mouse.click(action.x, action.y) + await page.waitForSelector('visbug-projective-transform') + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) + t.is(await page.$$('visbug-handles, visbug-rotation').then(items => items.length), 0) + + await page.click(target) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) + t.is(await page.$$('visbug-handles').then(items => items.length), 1) +}) + +test('dragging on a scrolled page keeps the opposite corner in place', async t => { + const {page} = t.context + const before = await page.evaluate(() => { + const source = document.createElement('button') + source.id = 'scrolled-projective-target' + source.textContent = 'Transform me' + source.style.cssText = 'position:absolute;left:120px;top:2200px;width:160px;height:70px' + document.body.appendChild(source) + window.scrollTo(0, 2050) + + const visbug = document.querySelector('vis-bug') + visbug.selectorEngine.select(source) + visbug.execCommand('3d-transform', {source}) + const quad = source.getBoxQuads()[0] + return {x: quad.p3.x, y: quad.p3.y} + }) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 25, handle.y + 15, {steps: 3}) + await page.mouse.up() + + const after = await page.$eval('#scrolled-projective-target', source => { + const quad = source.getBoxQuads()[0] + return {x: quad.p3.x, y: quad.p3.y} + }) + + t.true(Math.abs(after.x - before.x) < .1) + t.true(Math.abs(after.y - before.y) < .1) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) +}) diff --git a/app/utilities/common.js b/app/utilities/common.js index a10ae074..87977e22 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -91,6 +91,7 @@ export const isOffBounds = node => || node.closest('visbug-grip') || node.closest('visbug-gridlines') || node.closest('visbug-rotation') + || node.closest('visbug-projective-transform') ) export const isSelectorValid = (qs => ( diff --git a/app/utilities/strings.js b/app/utilities/strings.js index 3c4cc163..4ba43280 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-projective-transform)' From ec0821b4d374321f07c4fddf0e8b259d4640e4a5 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 28 Aug 2026 11:11:53 +0200 Subject: [PATCH 3/4] fix: keep spacing overlays visible during keyboard edits Stop margin and padding key handlers from hiding the selected handles for 750ms after every change, which made held-key adjustments appear frozen until input stopped. Reuse the existing box-model backdrop while refreshing handle geometry so transformed quad updates redraw the overlay in place. Clear reused backdrops when spacing reaches zero without replacing their host element. Add browser regression coverage for rapid margin and padding changes, asserting that the overlay remains visible, connected, and represented by the same DOM node throughout the edits. --- app/components/selection/box-model.element.js | 6 ++ app/components/selection/handles.element.js | 3 +- app/features/margin.js | 57 +++++++++-------- app/features/margin.test.js | 21 +++++++ app/features/padding.js | 63 ++++++++++--------- app/features/padding.test.js | 21 +++++++ 6 files changed, 113 insertions(+), 58 deletions(-) diff --git a/app/components/selection/box-model.element.js b/app/components/selection/box-model.element.js index 89182149..ecf32943 100644 --- a/app/components/selection/box-model.element.js +++ b/app/components/selection/box-model.element.js @@ -16,6 +16,12 @@ export class BoxModel extends HTMLElement { disconnectedCallback() {} set position(payload) { + if (!payload) { + this.drawable = {} + this.$shadow.innerHTML = '' + return + } + this.$shadow.innerHTML = this.render(payload) this.createMeasurements({...payload, ...this.drawable.measurementQuads}) } diff --git a/app/components/selection/handles.element.js b/app/components/selection/handles.element.js index fd1ff098..0edb82b6 100644 --- a/app/components/selection/handles.element.js +++ b/app/components/selection/handles.element.js @@ -44,6 +44,7 @@ export class Handles extends HTMLElement { set position({el, node_label_id}) { this.source_el = el + const backdrop = this.$shadow.querySelector('visbug-boxmodel') this.$shadow.innerHTML = this.render(getBoxQuad(el), node_label_id, isFixed(el)) const actions = this.$shadow.querySelector('visbug-selection-actions') @@ -51,7 +52,7 @@ export class Handles extends HTMLElement { if (this._backdrop) { this.backdrop = { - element: this._backdrop.update(el), + element: this._backdrop.update(el, false, backdrop), update: this._backdrop.update, } } diff --git a/app/features/margin.js b/app/features/margin.js index 34292275..db0ceb10 100644 --- a/app/features/margin.js +++ b/app/features/margin.js @@ -1,5 +1,5 @@ import hotkeys from 'hotkeys-js' -import { metaKey, getStyle, getSide, showHideSelected } from '../utilities/' +import { metaKey, getStyle, getSide } from '../utilities/' import { recordStyleChanges } from './history' const key_events = 'up,down,left,right' @@ -36,7 +36,6 @@ export function Margin(visbug, history) { } const updateMargin = (els, direction) => els - .map(el => showHideSelected(el)) .map(el => ({ el, style: 'margin' + getSide(direction), @@ -104,33 +103,37 @@ function removeBackgrounds(els) { }) } -export function createMarginVisual(el, hover = false) { +export function createMarginVisual( + el, hover = false, boxdisplay = document.createElement('visbug-boxmodel') +) { const bounds = el.getBoundingClientRect() const calculatedStyle = getStyle(el, 'margin') - const boxdisplay = document.createElement('visbug-boxmodel') - - if (calculatedStyle !== '0px') { - const sides = { - top: getStyle(el, 'marginTop'), - right: getStyle(el, 'marginRight'), - bottom: getStyle(el, 'marginBottom'), - left: getStyle(el, 'marginLeft'), - } - - Object.entries(sides).forEach(([side, val]) => { - if (typeof val !== 'number') - val = parseInt(getStyle(el, 'margin'+'-'+side).slice(0, -2)) - - sides[side] = Math.round(val.toFixed(1) * 100) / 100 - }) - - boxdisplay.position = { - mode: 'margin', - color: hover ? 'purple' : 'pink', - bounds, - sides, - element: el - } + + if (calculatedStyle === '0px') { + boxdisplay.position = null + return boxdisplay + } + + const sides = { + top: getStyle(el, 'marginTop'), + right: getStyle(el, 'marginRight'), + bottom: getStyle(el, 'marginBottom'), + left: getStyle(el, 'marginLeft'), + } + + Object.entries(sides).forEach(([side, val]) => { + if (typeof val !== 'number') + val = parseInt(getStyle(el, 'margin'+'-'+side).slice(0, -2)) + + sides[side] = Math.round(val.toFixed(1) * 100) / 100 + }) + + boxdisplay.position = { + mode: 'margin', + color: hover ? 'purple' : 'pink', + bounds, + sides, + element: el } return boxdisplay diff --git a/app/features/margin.test.js b/app/features/margin.test.js index a52ad263..8cb43127 100644 --- a/app/features/margin.test.js +++ b/app/features/margin.test.js @@ -81,4 +81,25 @@ test('Can change values by 10 with shift key', async t => { t.pass() }) +test('Keeps the box-model overlay visible and updates it in place', async t => { + const { page } = t.context + + await page.click(test_selector) + await page.evaluate(() => { + const handles = document.querySelector('visbug-handles') + window.marginBoxModel = handles.$shadow.querySelector('visbug-boxmodel') + }) + + for (let i = 0; i < 3; i++) { + await page.keyboard.press('ArrowUp') + const overlay = await page.$eval('visbug-handles', handles => ({ + visible: handles.style.display !== 'none', + connected: window.marginBoxModel.isConnected, + sameNode: handles.$shadow.querySelector('visbug-boxmodel') === window.marginBoxModel, + })) + + t.deepEqual(overlay, {visible: true, connected: true, sameNode: true}) + } +}) + test.afterEach.always(teardownPptrTab) diff --git a/app/features/padding.js b/app/features/padding.js index 8285122d..2f18d494 100644 --- a/app/features/padding.js +++ b/app/features/padding.js @@ -1,5 +1,5 @@ import hotkeys from 'hotkeys-js' -import { metaKey, getStyle, getSide, showHideSelected, expandBorders } from '../utilities/' +import { metaKey, getStyle, getSide, expandBorders } from '../utilities/' import { recordStyleChanges } from './history' const key_events = 'up,down,left,right' @@ -36,7 +36,6 @@ export function Padding(visbug, history) { } const updatePadding = (els, direction) => els - .map(el => showHideSelected(el)) .map(el => ({ el, style: 'padding' + getSide(direction), @@ -104,37 +103,41 @@ function removeBackgrounds(els) { }) } -export function createPaddingVisual(el, hover = false) { +export function createPaddingVisual( + el, hover = false, boxdisplay = document.createElement('visbug-boxmodel') +) { const bounds = el.getBoundingClientRect() const calculatedStyle = getStyle(el, 'padding') const calculatedBorder = expandBorders(getStyle(el, 'border-width')) - const boxdisplay = document.createElement('visbug-boxmodel') - - if (calculatedStyle !== '0px') { - const sides = { - top: getStyle(el, 'paddingTop'), - right: getStyle(el, 'paddingRight'), - bottom: getStyle(el, 'paddingBottom'), - left: getStyle(el, 'paddingLeft'), - } - - Object.entries(sides).forEach(([side, val]) => { - if (typeof val !== 'number') - val = parseInt(getStyle(el, 'padding'+'-'+side).slice(0, -2)) - - sides[side] = Math.round(val.toFixed(1) * 100) / 100 - }) - - boxdisplay.position = { - mode: 'padding', - color: hover ? 'purple' : 'pink', - bounds, - sides: { - ...sides, - borders: calculatedBorder, - }, - element: el - } + + if (calculatedStyle === '0px') { + boxdisplay.position = null + return boxdisplay + } + + const sides = { + top: getStyle(el, 'paddingTop'), + right: getStyle(el, 'paddingRight'), + bottom: getStyle(el, 'paddingBottom'), + left: getStyle(el, 'paddingLeft'), + } + + Object.entries(sides).forEach(([side, val]) => { + if (typeof val !== 'number') + val = parseInt(getStyle(el, 'padding'+'-'+side).slice(0, -2)) + + sides[side] = Math.round(val.toFixed(1) * 100) / 100 + }) + + boxdisplay.position = { + mode: 'padding', + color: hover ? 'purple' : 'pink', + bounds, + sides: { + ...sides, + borders: calculatedBorder, + }, + element: el } return boxdisplay diff --git a/app/features/padding.test.js b/app/features/padding.test.js index 4fb7e932..a2ca8d41 100644 --- a/app/features/padding.test.js +++ b/app/features/padding.test.js @@ -81,4 +81,25 @@ test('Can change values by 10 with shift key', async t => { t.pass() }) +test('Keeps the box-model overlay visible and updates it in place', async t => { + const { page } = t.context + + await page.click(test_selector) + await page.evaluate(() => { + const handles = document.querySelector('visbug-handles') + window.paddingBoxModel = handles.$shadow.querySelector('visbug-boxmodel') + }) + + for (let i = 0; i < 3; i++) { + await page.keyboard.press('ArrowUp') + const overlay = await page.$eval('visbug-handles', handles => ({ + visible: handles.style.display !== 'none', + connected: window.paddingBoxModel.isConnected, + sameNode: handles.$shadow.querySelector('visbug-boxmodel') === window.paddingBoxModel, + })) + + t.deepEqual(overlay, {visible: true, connected: true, sameNode: true}) + } +}) + test.afterEach.always(teardownPptrTab) From 56a21109bb783352e9d24b04c80e3a602ae46534 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Fri, 28 Aug 2026 11:24:11 +0200 Subject: [PATCH 4/4] fix: keep projective overlay synchronized Refresh projective handles after history changes and throughout CSS transform transitions so undo and redo keep the active overlay aligned with the restored element geometry. Suppress standard selection overlays instead of deleting them, preserve their original display values, and restore them before another VisBug tool initializes. Prevent modifier-key handling from exposing suppressed overlays. Delay outside-click registration past selection-menu activation and add browser coverage for transition-aware undo, safe tool changes, overlay restoration, and existing scroll and interaction behavior. --- .../selection/projective-transform.element.js | 113 ++++++++++++++++-- app/components/vis-bug/vis-bug.element.js | 3 + app/features/selectable.js | 3 +- app/plugins/projective-transform.js | 4 +- app/plugins/projective-transform.test.js | 64 +++++++++- 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/app/components/selection/projective-transform.element.js b/app/components/selection/projective-transform.element.js index aff6f2c4..c1bb0446 100644 --- a/app/components/selection/projective-transform.element.js +++ b/app/components/selection/projective-transform.element.js @@ -28,6 +28,11 @@ export class ProjectiveTransform extends HTMLElement { this.on_pointer_up = this.on_pointer_up.bind(this) this.on_position_change = this.on_position_change.bind(this) this.on_document_click = this.on_document_click.bind(this) + this.on_history_change = this.on_history_change.bind(this) + this.on_tool_change = this.on_tool_change.bind(this) + this.on_transition_run = this.on_transition_run.bind(this) + this.on_transition_end = this.on_transition_end.bind(this) + this.refresh_transition = this.refresh_transition.bind(this) } connectedCallback() { @@ -38,13 +43,16 @@ export class ProjectiveTransform extends HTMLElement { this.svg.addEventListener('pointerdown', this.on_pointer_down) window.addEventListener('resize', this.on_position_change) window.addEventListener('scroll', this.on_position_change, true) - document.addEventListener('click', this.on_document_click, true) + this.outside_click_timer = setTimeout(() => { + if (this.isConnected) + document.addEventListener('click', this.on_document_click, true) + }) + document.addEventListener('visbug-tool-change', this.on_tool_change) this.source_observer = new MutationObserver(this.on_position_change) - if (this.source_el) this.source_observer.observe(this.source_el, { - attributes: true, - attributeFilter: ['class', 'style', 'data-selected'], - }) + this.observe_source() + this.stop_history_refresh = document.querySelector('vis-bug')?.history + ?.subscribe(this.on_history_change) this.setAttribute('popover', 'manual') this.showPopover && this.showPopover() @@ -56,21 +64,30 @@ export class ProjectiveTransform extends HTMLElement { this.svg?.removeEventListener('pointerdown', this.on_pointer_down) window.removeEventListener('resize', this.on_position_change) window.removeEventListener('scroll', this.on_position_change, true) + clearTimeout(this.outside_click_timer) document.removeEventListener('click', this.on_document_click, true) + document.removeEventListener('visbug-tool-change', this.on_tool_change) this.source_observer?.disconnect() + this.source_el?.removeEventListener('transitionrun', this.on_transition_run) + this.source_el?.removeEventListener('transitionend', this.on_transition_end) + this.source_el?.removeEventListener('transitioncancel', this.on_transition_end) + this.stop_history_refresh?.() this.position_frame && cancelAnimationFrame(this.position_frame) + this.history_frame && cancelAnimationFrame(this.history_frame) + this.transition_frame && cancelAnimationFrame(this.transition_frame) + this.restore_suppressed_overlays() this.stop_drag() } set source(element) { this.source_observer?.disconnect() + this.source_el?.removeEventListener('transitionrun', this.on_transition_run) + this.source_el?.removeEventListener('transitionend', this.on_transition_end) + this.source_el?.removeEventListener('transitioncancel', this.on_transition_end) this.source_el = element if (this.isConnected && element) { - this.source_observer.observe(element, { - attributes: true, - attributeFilter: ['class', 'style', 'data-selected'], - }) + this.observe_source() this.update_position() } } @@ -79,6 +96,43 @@ export class ProjectiveTransform extends HTMLElement { return this.source_el } + suppress_overlays(overlays) { + this.restore_suppressed_overlays() + this.suppressed_overlays = overlays.map(element => ({ + element, + display: element.style.getPropertyValue('display'), + priority: element.style.getPropertyPriority('display'), + })) + this.suppressed_overlays.forEach(({element}) => { + element.setAttribute('data-projective-suppressed', '') + element.style.setProperty('display', 'none', 'important') + }) + } + + restore_suppressed_overlays() { + this.suppressed_overlays?.forEach(({element, display, priority}) => { + if (!element.isConnected) return + + element.removeAttribute('data-projective-suppressed') + display + ? element.style.setProperty('display', display, priority) + : element.style.removeProperty('display') + }) + this.suppressed_overlays = null + } + + observe_source() { + if (!this.source_el) return + + this.source_observer.observe(this.source_el, { + attributes: true, + attributeFilter: ['class', 'style', 'data-selected'], + }) + this.source_el.addEventListener('transitionrun', this.on_transition_run) + this.source_el.addEventListener('transitionend', this.on_transition_end) + this.source_el.addEventListener('transitioncancel', this.on_transition_end) + } + on_position_change() { if (!this.source_el?.isConnected || !this.source_el.hasAttribute('data-selected')) { this.remove() @@ -96,6 +150,47 @@ export class ProjectiveTransform extends HTMLElement { if (!event.composedPath().includes(this)) this.remove() } + on_tool_change() { + this.remove() + } + + on_history_change() { + if (!this.isConnected) return + + this.update_position() + this.history_refreshes = 2 + if (!this.history_frame) + this.history_frame = requestAnimationFrame(() => this.refresh_history()) + } + + refresh_history() { + this.history_frame = null + this.update_position() + if (this.history_refreshes-- > 0) + this.history_frame = requestAnimationFrame(() => this.refresh_history()) + } + + on_transition_run(event) { + if (event.target !== this.source_el || event.propertyName !== 'transform') return + if (!this.transition_frame) + this.transition_frame = requestAnimationFrame(this.refresh_transition) + } + + on_transition_end(event) { + if (event.target !== this.source_el || event.propertyName !== 'transform') return + if (this.transition_frame) cancelAnimationFrame(this.transition_frame) + this.transition_frame = null + this.update_position() + } + + refresh_transition() { + this.transition_frame = null + if (!this.isConnected || this.pointer_id !== undefined) return + + this.update_position() + this.transition_frame = requestAnimationFrame(this.refresh_transition) + } + update_position() { if (!this.path || !this.source_el?.isConnected) return diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index ad201d57..90d93e98 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -190,6 +190,9 @@ export default class VisBug extends HTMLElement { el.attr('data-active', true) this.active_tool = el + document.dispatchEvent(new CustomEvent('visbug-tool-change', { + detail: {tool: el.dataset.tool}, + })) this[el.dataset.tool]() } diff --git a/app/features/selectable.js b/app/features/selectable.js index 10742c7f..b38042ef 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -172,7 +172,8 @@ export function Selectable(visbug, history) { document.onkeyup = function(e) { if (did_hide) { $('visbug-handles, visbug-label, visbug-hover, visbug-grip, visbug-rotation').forEach(el => - el.style.display = null) + !el.hasAttribute('data-projective-suppressed') + && (el.style.display = null)) did_hide = false } diff --git a/app/plugins/projective-transform.js b/app/plugins/projective-transform.js index 44299e49..8043d8ac 100644 --- a/app/plugins/projective-transform.js +++ b/app/plugins/projective-transform.js @@ -23,11 +23,11 @@ export default function projectiveTransform({selected, source}) { document.querySelectorAll('visbug-projective-transform') .forEach(overlay => overlay.remove()) - document.querySelectorAll(selectionOverlays) - .forEach(overlay => overlay.remove()) const overlay = document.createElement('visbug-projective-transform') overlay.source = element + overlay.suppress_overlays(Array.from( + document.querySelectorAll(selectionOverlays))) document.body.appendChild(overlay) return overlay } diff --git a/app/plugins/projective-transform.test.js b/app/plugins/projective-transform.test.js index 7853923c..23ebe8b4 100644 --- a/app/plugins/projective-transform.test.js +++ b/app/plugins/projective-transform.test.js @@ -13,6 +13,25 @@ const shortcut = async (page, modifier, {shift = false} = {}) => { await page.keyboard.up(modifier) } +const overlayMatchesSource = target => { + const source = document.querySelector(target) + const overlay = document.querySelector('visbug-projective-transform') + if (!source || !overlay) return false + + const quad = source.getBoxQuads()[0] + const sourcePoints = [quad.p1, quad.p2, quad.p3, quad.p4] + const overlayPoints = Array.from(overlay.$shadow.querySelectorAll('[data-corner]')) + .filter(element => element.tagName === 'g') + .map(group => { + const matrix = group.transform.baseVal.consolidate().matrix + return {x: matrix.e, y: matrix.f} + }) + + return overlayPoints.every((point, index) => + Math.abs(point.x - sourcePoints[index].x) < .1 + && Math.abs(point.y - sourcePoints[index].y) < .1) +} + test.beforeEach(setupPptrTab) test.afterEach.always(teardownPptrTab) @@ -26,9 +45,9 @@ test('shows four projective handles and supports undo and redo', async t => { const overlays = await page.evaluate(() => ({ projective: document.querySelectorAll('visbug-projective-transform').length, - regular: document.querySelectorAll( + regular: Array.from(document.querySelectorAll( 'visbug-handles, visbug-label, visbug-rotation, visbug-hover' - ).length, + )).filter(element => getComputedStyle(element).display !== 'none').length, handles: document.querySelector('visbug-projective-transform') .$shadow.querySelectorAll('.handle').length, crosses: document.querySelector('visbug-projective-transform') @@ -58,6 +77,7 @@ test('shows four projective handles and supports undo and redo', async t => { await shortcut(page, modifier) t.is(await page.$eval(target, element => element.style.transform), '') + await page.waitForFunction(overlayMatchesSource, {}, target) await shortcut(page, modifier, {shift: true}) t.is(await page.$eval(target, element => element.style.transform), transformed) @@ -67,6 +87,41 @@ test('shows four projective handles and supports undo and redo', async t => { t.is(await page.$$('visbug-handles').then(items => items.length), 1) }) +test('tracks undo through a transform transition', async t => { + const {page} = t.context + await page.$eval(target, element => { + element.style.transition = 'transform 150ms linear' + }) + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 30, handle.y + 20, {steps: 3}) + await page.mouse.up() + + await page.evaluate(() => document.querySelector('vis-bug').history.undo()) + await page.waitForFunction(overlayMatchesSource, {}, target) + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) +}) + +test('changing tools closes the projective overlay', async t => { + const {page} = t.context + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + await page.evaluate(() => document.querySelector('vis-bug').toolSelected('margin')) + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) +}) + test('selection action replaces the regular selection overlays', async t => { const {page} = t.context await page.click(target) @@ -86,7 +141,10 @@ test('selection action replaces the regular selection overlays', async t => { await page.waitForSelector('visbug-projective-transform') t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) - t.is(await page.$$('visbug-handles, visbug-rotation').then(items => items.length), 0) + const regularOverlaysHidden = await page.$$eval( + 'visbug-handles, visbug-rotation', + elements => elements.every(element => getComputedStyle(element).display === 'none')) + t.true(regularOverlaysHidden) await page.click(target) t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0)